Repository navigation
feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports - #20125
Conversation
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
…low the tree Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 11 package(s): 102 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 154 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 4adae22f9950458e57fe0aebfd63fdceb5847261 && git checkout 4adae22f9950458e57fe0aebfd63fdceb5847261
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f09d4122bc8c2eb39414b83b1924217a04b66cb2 e4ead748dfe6e589a00cde829769643a6b51d0af && git checkout -B drift-repro f09d4122bc8c2eb39414b83b1924217a04b66cb2 && git merge --no-ff e4ead748dfe6e589a00cde829769643a6b51d0af
node scripts/docs-audit/affected-docs.mjs --json f09d4122bc8c2eb39414b83b1924217a04b66cb2
|
…tire-saved-report-stack
…igures, census rows and baseline ceiling follow the retired family Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
…on at the os validate / os build door renderCapabilityMessage's unknown branch now consults RETIRED_PLATFORM_CAPABILITY_GUIDANCE, so a plain-object config declaring requires: ['reports'] gets the same prescription defineStack refuses it with and os serve warns with, instead of "check for a typo". Posture unchanged: an unknown token stays a non-fatal advisory at that door. The published platform skill's CAPABILITY_PROVIDERS table drops the retired token and states its real count (19 of 20; package-registry is always-on). Three stale comments follow the retirement: the disjointness pin's file, the setInterval-owning Plugin count (measured: one), and the retired sys_saved_report / sys_report_schedule format paths. Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
… directly Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
…n ceiling Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
…ved with PageSchema's declared default (objectstack-ai#20101) (objectstack-ai#20133) Fixes objectstack-ai#20101 Clause-②: no ## What this changes `PageSchema` declares `type: PageTypeSchema.default('record')` (`packages/spec/src/ui/page.zod.ts`), so a page authored without `type` is a record page. `saveMetaItem` parsed the body with that default, then stored the authored body verbatim, and every `/meta` read serves a stored row unparsed. A page saved without `type` was therefore stored and served without it. One helper, `withDeclaredPageTypeDefault` in `packages/metadata-protocol/src/protocol.ts`, reads the default from the registered `page` schema (`getMetadataTypeSchema('page').shape.type`, the schema the save gate validates with). It fills the default only when `type` is absent, so no `'record'` literal is added. It runs at two seams: - **Save.** It runs in `saveMetaItem` after the schema gate accepts the body, beside the two existing grafts. New rows store the key, on draft and publish saves alike. - **Read.** It runs in `convertStoredItem`, the rehydration seam every stored row passes through. Rows stored before this change are served with the default. The row itself is never rewritten. The read fill sits in `convertStoredItem` and not in `convertStoredItemDetailed`. It is not an ADR-0087 conversion and emits no notice, so `migrateStoredMetadata` (the Detailed caller) has nothing to rewrite. Scope is `PageSchema.type` only. The regions of `protocol.ts` that draft PR objectstack-ai#20125 edits are not touched: this diff's hunks are at the `resolveOverlaySchema` docblock, after `graftFoldedFormSections`, in `convertStoredItem` and in the `saveMetaItem` schema-gate block. ## Why both seams (H2, measured) - **Read alone** covers old rows, but it breaks objectstack-ai#4326's invariant that a GET → PUT round-trip is byte-identical. The served body would carry a key the row does not have. The first re-save of every such page would then write a one-key change, a new checksum and a history row that nobody authored. Ablation leg B below shows this: with the save fill removed, the round-trip pin goes red. - **Save alone** leaves every row stored before this change typeless on every read. Covering those rows would need a stored-row rewrite, which triage point 2 forbids. Ablation leg A below shows this. - **Both together** is the smallest fix that is correct. Measured below: a new row round-trips with the same bytes, checksum and version, and an old row is served typed without being rewritten. ## Serve paths: base `226e00c038` vs head (H1) The measurement used a typeless page, an explicit `type: 'app'` control, a typeless draft, and a typeless row seeded directly into the store (the "old row"). It ran at three levels: the real `RestServer` over the real protocol, the protocol alone, and the real composition (`bootStack(showcase)` plus `MetadataPlugin` over a build-shaped artifact, over real HTTP). | Path | base: typeless | base: `app` control | head: typeless | head: `app` control | |:---|:---|:---|:---|:---| | `saveMetaItem` publish: stored body | absent | `app` | `record` | `app` | | `saveMetaItem` draft: stored draft body | absent | n/a | `record` | n/a | | `publishMetaItem` of that draft: stored and served | absent | n/a | `record` | n/a | | Registry write-through on save (unscoped kernel) | absent | `app` | `record` | `app` | | `GET /meta/page` (`getMetaItems`), cache on and off, new and old row | absent | `app` | `record` | `app` | | `GET /meta/page?preview=draft` | absent | n/a | `record` | n/a | | `GET /meta/page/:name`, cached arm (`getMetaItemCached`) | absent, ETag `7811092d` | `app`, ETag `6164defa` | `record`, ETag `69c54eb6` | `app`, ETag `6164defa` (unchanged) | | `GET /meta/page/:name`, uncached arm (`getMetaItem`) | absent | `app` | `record` | `app` | | `GET /meta/page/:name?state=draft` | absent | n/a | `record` | n/a | | `getMetaItem` with `previewDrafts` | absent | n/a | `record` | n/a | | `getMetaItemLayered`, old row (overlay / effective) | absent / absent | n/a | `record` / `record` | n/a | | `searchAll` page hit `pageType` | absent | `app` | `record` | `app` | | `loadMetaFromDb` boot hydration into the registry | absent | `app` | `record` | `app` | | Old row at rest, after all the reads above | absent | n/a | absent (not rewritten) | n/a | | `migrateStoredMetadata` preview | scanned 4, canonical 4, pending 0 | | scanned 4, canonical 4, pending 0 | | | Real composition over HTTP: list, single, `?state=draft`, published draft | absent | `app` (ETag `100766d6`) | `record` | `app` (ETag `100766d6`, unchanged) | ## Consumers of the served page body (H3) Every measured reading below used the explicit `app` page as its control. - **Server-side record-page selection by `type`: none in this repo.** A grep of non-test `packages/**` for reads of a page's `type` finds two readers. One is the `searchAll` page sweep (`pageType`, measured). The other is the lint rule in `validate-page-visualization-bindings.ts`, which asks `page.type !== 'list'`; its verdict cannot change, because `record` is not `list`. Record-page selection lives in objectui's `usePageAssignment`, per the card, and that is UNMEASURED here because objectui is not reachable from this session. - **Measured to carry the fill:** the list, single, cached, draft-state, preview and layered reads; boot hydration; the registry write-through; and the `searchAll` sweep. See the table above. - **Carry the fill by code reading, not separately exercised:** these read through `convertStoredItem` or `getMetaItems`. They are the runtime authoring gate's stored universe (`foldStoredCollection`), the `duplicatePackage` copy (which is then saved through `saveMetaItem`), and the runtime package-export sweep (`getMetaItems`). - **Deliberately raw:** `historyMetaItem` and `diffMetaItem` history bodies, which are a record of what was written, and `getEffectiveLock`, which reads protection only. - **Outside this card's surface, not filled:** the `metadata` service's `DatabaseLoader.rowToData` (`packages/metadata`). In the composition measured (showcase plus `MetadataPlugin`), every read of the test pages served the typed body. - **Hashes.** The row checksum (the `sha256:` token `If-Match` compares) is computed over the stored body, so a read cannot move it. The cached read's ETag is a hash of the served content. It changes once for a typeless page and is unchanged for the `app` control. - **Round-trip.** Serving the new row, then PUTting it back, stores the same bytes, checksum and version: the repository's identical-body short-circuit fires and nothing is written. Serving the old row and PUTting it back stores `type` once, and the served body is identical before and after. That is the author's re-save, not a migration. ## Tests All runs are at head `e3b83df825`. The probes ran at `c2a29b4573`, and `protocol.ts` is byte-identical between the two commits. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 188 files passed, 3 skipped; 2686 tests passed, 19 skipped; exit 0. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` includes all 191 test files, among them the three edited here. - REST `/meta` route tests: every `packages/rest/src/*meta*.test.ts`, 47 files, 667 tests passed, exit 0. They read the rebuilt `metadata-protocol` `dist/`, which contains `withDeclaredPageTypeDefault` (4 hits). - Dogfood metadata round-trip tests: `dashboard-designer-roundtrip`, `meta-published-and-state-routes`, `package-first-authoring`, `meta-types-create-seed`, `showcase-object-extension-meta-read` and `showcase-object-extension-scalar-divergence`. 6 files, 33 tests passed, exit 0. New pins: - `protocol.stored-conversions.test.ts`: a stored typeless page row is served with the declared default by the list and single reads, the draft read and the list preview, and boot hydration. An explicit `app` row is untouched. The row's bytes survive the reads, and `migrateStoredMetadata` preview and apply report it canonical, with `rewritten: 0`. - `protocol.read-decorations.test.ts`: publish and draft saves store the default. An explicit `app` is stored and served unchanged. GET → PUT gives the same stored body, checksum and version. - `protocol.search-published-pages.test.ts`: a typeless page's hit carries `pageType` equal to the declared default. Every pin reads the expected value from the schema. A precondition asserts that value is `record`. **Ablation.** Each leg ran through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed) with a trap restore, and restore was proven each time (blob `1df09c2eb4` equals HEAD, `git diff HEAD` empty). These suites import `./protocol.js` from source, so no rebuild was involved. `ablation-dist-preflight --absent` confirms the markers never reached `dist/`. - Leg A, read fill removed: 4 failed of 48. These are the three stored-conversions read pins and the search pin. - Leg B, save fill removed: 2 failed of 48. These are the stored-body pin and the GET → PUT byte-identity pin. - Restored: 48 of 48 passed. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived at `e3b83df825`: 61 commands. All 61 were run with exit codes recorded, and `--ran` reports 61 run, 0 NOT-MEASURED (a derived zero). - `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist/`). It passed after those were built. - `node scripts/check-issue-citations.mjs --base 226e00c`: 5 citations, all resolve. - Lint, narrowed: `eslint --no-inline-config --format json` over the 4 changed `.ts` files reports 4 files, 0 errors, 0 warnings. `isPathIgnored` is false for them. `calculateConfigForFile` shows neither `parserOptions.project` nor `projectService` set, so linting is not type-aware and this diff cannot change the verdict on any file it does not touch. The full `pnpm lint` is left to CI. ## Acceptance notes - **Other declared defaults (triage point 3, census, not in this diff).** Read from the built spec at `226e00c038`: 22 stored metadata types were walked (types with `allowRuntimeCreate` or `allowOrgOverride`). 15 of them declare 39 top-level defaults. The positive control `page.type` is found, and the negative control `page.object` (optional, no default) is not. `external_catalog` has no registered schema. The save path stores all of these verbatim. Only `page.type` was measured through the serve paths. The 38 besides `page.type`: - `object`: `isSystem`, `datasource` - `hook`: `priority`, `async`, `onError`, `runAs` - `seed`: `externalId`, `mode`, `env` - `mapping`: `sourceFormat`, `mode` - `view`: `type` (`simple`) - `page`: `template`, `regions`, `isDefault`, `kind` (`full`) - `app`: `active`, `isDefault` - `action`: `type` (`script`), `refreshAfter` - `report`: `type` (`tabular`), `drilldown` - `flow`: `version`, `status`, `runAs` - `datasource`: `active`, `autoConnect`, `schemaMode`, `origin` - `email_template`: `category`, `locale`, `variables`, `active`, `isSystem` - `permission`: `isDefault` - `position`: `delegatable` - `skill`: `surface`, `active` - The renderer half of triage point 4 ("is picked as the object's record page") is objectui's `usePageAssignment`, and it is UNMEASURED from this session. What this PR pins is the served body: `type` equal to the declared default. - The cached single read's ETag for a page stored without `type` changes once, because the served content changed. The `app` control's ETag is unchanged. - The first re-save of an old typeless page stores `type` once, with one history row. From then on its round-trip writes nothing. - A stored row that spells the page type under a key `PageSchema` refuses (for example the `pageType` alias key, which is reachable only by a write that went around the save gate) is now read with `type: 'record'` beside that key. Such a row fails the schema either way and carries `_diagnostics`. No such row was measured in any data. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rt, meta history and search (objectstack-ai#20061, objectstack-ai#20062) (objectstack-ai#20137) Fixes objectstack-ai#20061 Fixes objectstack-ai#20062 Clause-②: no (narrowing) ## What Four published REST doors read `?limit=` with a bare `Number()`. They substituted, clamped or dropped the result and answered `200`: - `GET /data/import/jobs` - `GET /data/:object/export` - `GET /meta/:type/:name/history` - `GET /search` Each door now reads `limit` against its own declaration and refuses a value outside it. `GET /data/import/jobs` does the same for `offset`, because its declaration bounds `offset` too. The refusal is `400` with the data surface's existing `VALIDATION_FAILED` + `fields[]` envelope, and the service is never called. Nothing a conforming caller sends changes its answer. The change is one private reader in `packages/rest/src/rest-server.ts`, `readDeclaredQueryNumber`, with no new package export: - **import jobs** read through `ListImportJobsRequestSchema.shape.limit` / `.offset`; - **history** reads through `HistoryMetaItemRequestSchema.shape.limit`; - **export** and **search** declare no request schema, so they read through a module-private `z.number().int().optional()`: a whole number, nothing about range. A non-blank numeric string is coerced with `Number()` and handed to the schema. Anything else (`abc`, a blank string, a structured value) goes to the schema as it came, so the declaration refuses it by type. `Number()` never gets to invent a `0` or a `NaN` for it. ## Before and after, measured on the real `RestServer` routes (spy protocol) Every row answered `200` before this PR. | door | request | answered before | answers now | |:--|:--|:--|:--| | import jobs | `?limit=0` / `?limit=-3` | limit 50 / 1 | `400`, `fields[0]` = `limit` / `min_value` | | import jobs | `?limit=201` / `?limit=500` | limit 200 | `400`, `max_value` | | import jobs | `?limit=abc` / `1.5` / `Infinity` / blank | limit 50 / 1.5 / 200 / 50 | `400`, `invalid_type` | | import jobs | `?offset=-1` / `abc` / `1.5` | offset 0 / 0 / 1.5 | `400`, `min_value` / `invalid_type` | | export | `?limit=abc` / empty / blank | `X-Export-Limit: 1` (a one-row export) | `400`, `invalid_type` | | export | `?limit=1.5` / `Infinity` | `X-Export-Limit: 1.5` / `50000` | `400`, `invalid_type` | | meta history | `?limit=abc` / `Infinity` | limit dropped: the whole change log | `400`, `invalid_type` | | meta history | `?limit=` (empty) / blank | limit 0: zero events | `400`, `invalid_type` | | search | `?limit=abc` | `limit: NaN` handed to `searchAll`: no overall cap | `400`, `invalid_type` | | search | `?limit=1.5` / `Infinity` / blank | 1.5 / clamped to 100 / 0 | `400`, `invalid_type` | Unchanged and pinned by the lit controls: - **Import jobs:** absent or empty `limit` → 50, absent or empty `offset` → 0. Conforming values reach `findData` unchanged. - **Export:** absent → 10000. `?limit=25` → 25. The door's own range handling is untouched: `?limit=0` → 1 and `?limit=60000` → 50000. - **History:** absent → no `limit` member. `5`, `0` and `1.5` are forwarded unchanged, because the declaration is `z.number()` with no `int()` and no bounds. - **Search:** absent or empty → `undefined`. `20`, `0` and `500` reach `searchAll` unchanged, and its `[1, 100]` clamp is its own business. The refusal body carries: - `code: 'VALIDATION_FAILED'`; - `fields: [{ field: 'limit', code, message }]`; - an `error` sentence naming the parameter; - `object`, on the export door only. The handler throws `validationFailure` (`@objectstack/types`), and each door's existing catch (`handleRouteError` / `mapDataError`) writes the 400. So `rest-server.ts` gains no response write site, and `check:route-envelope`'s `siblingCode` count on it is unchanged (green). ## PM mechanism assumptions, measured 1. **The `parseIntegerParam` cycle: confirmed.** `packages/runtime/package.json` depends on `@objectstack/rest`. rest's dependencies are `core`, `metadata-core`, `observability`, `platform-objects`, `service-package`, `spec`, `types` (plus `exceljs`, `zod`). Nothing was moved; `packages/runtime/**` and `packages/spec/**` are untouched. 2. **The declared-schema idiom holds, with one measured difference in `fields[].code`.** zod 4.6.1 against the declared schemas, mapped through `zodIssuesToFields`: - `NaN`, a string, `Infinity`, and `1.5` against `int()` all give `invalid_type`; - below `min` gives `min_value`; above `max` gives `max_value`. These are all ADR-0114 catalog members, never zod's own codes. Runtime's `parseIntegerParam` answers `invalid_number` for the same `abc` / `1.5`; see the open question in the report. 3. **The doors match the card, with two corrections.** - Export serves no declared request schema. `CreateExportJobRequestSchema` is the contract of `POST /api/v1/data/:object/export`, which rest does not mount. `ExportRequestSchema` (`contract.zod.ts`) is bound to no route. - The search door holds: the only production caller of `searchAll` is the REST door (`git grep`, tests excluded). So `packages/metadata-protocol/src/protocol.ts` is not in this diff. 4. **The empty-string premise is partly falsified.** Only import jobs and search answered `?limit=` with their default. Export answered a one-row export (`Number('') || 0` → `Math.max(1, 0)`), and history answered zero events (`Number('')` = 0, forwarded). The rule applied is the one `parseEnumParam` (runtime `query-param.ts`) states for the same spelling: - Where the old answer already was the absent answer, empty stays absent. - Where the old answer was an invented `0`, empty is refused. Reading it as absent on export and history would have turned those two into a 10000-row export and the whole change log. That grows the window, the widening this claim stops on, so the reader carries an explicit per-door `emptyIsAbsent`. No door accepts anything it refused before: none refused any single-valued `limit` before this PR. No conforming value's answer changes either, so the declaration stays `no (narrowing)`. ## Tests The new file is `packages/rest/src/rest-server-limit-param-parsing.test.ts` (43 cases). Each refusal pins `status` 400, `code` `VALIDATION_FAILED`, `fields[0].field` and `fields[0].code`, and that the service spy was never called. Each lit control pins the argument the service received. - Against unfixed `6780e34a`: 24 failed (every refusal row answered 200), 19 passed (every lit control). - Against the fix: 43 passed. One existing fixture was corrected in `rest-server-closed-query-params.test.ts`. Its search closed-set loop sent `limit: 'x'` and expected 200; it now takes a valid value per name, as its export twin in the same file already does. Runs at head `74898ed5`, after merging `origin/main`: - **Rest suite:** `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2` → `Test Files 196 passed (196)`, `Tests 3327 passed | 1 skipped (3328)`. - **Typecheck:** `pnpm --filter @objectstack/rest run typecheck` → exit 0. `tsc --noEmit` passes, and `check:test-typecheck: OK — ... 0 file(s) / 0 error(s)`. Ablations went through `scripts/ablation-replace.mjs` in WRAP mode, run after the fix was committed. Vitest reads `src/` through the relative import, so no dist was involved. - **Import jobs:** restoring `Math.min(200, Math.max(1, Number(q.limit) || 50))` landed on disk (anchor 1 → 0, blob `02ceccd1` → `cab07300`). Result: `Tests 8 failed | 35 passed`, exactly the 8 import-jobs `limit` refusal rows. - **Search:** restoring `req.query?.limit ? Number(req.query.limit) : undefined` landed (blob `02ceccd1` → `9b589c00`). Result: `Tests 4 failed | 39 passed`, exactly the 4 search refusal rows. - Both restores were proven: blob == HEAD `02ceccd1`, `git diff HEAD` empty. Public surface: rest's `dist/index.d.ts` and `dist/index.d.cts`, built from merge-base `bc80e162`'s `rest-server.ts` and from HEAD's, are byte-identical (`cmp`). The restore was proven by blob hash. So no importing package's types can move, and only rest's own tests are owed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the real diff gives 61 commands, identical to the PM's list. All ran at `74898ed5` with exit codes captured before any pipe. `--ran` reconciliation: `61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN`. - **59 exit 0.** This includes `check:adr-0087-registration --base origin/main` (`not-required (no-migration-prescription)` accepted), `check:changeset-no-major`, `check:empty-changeset`, `check:route-envelope`, `check:nul-bytes`, `check:doc-authoring`, `check:published-files`, `check:issue-citations` and `check:undeclared-dep-imports`. - **NOT MEASURED: `check:dual-build-cjs-loads`**, exit 3 `PREREQUISITE NOT MET`: 43 workspace packages have no `dist/`. Declared narrowing: `require('./dist/index.cjs')` and `import('@objectstack/rest')` both load and expose `RestServer`. - **NOT MEASURED: `check:type-check-debt`**, exit 3 `PREREQUISITE NOT MET`: the full closure is not built. See the `.d.ts` identity above; CI builds the closure before this step. - **`pnpm lint`** (the lane addition) ran as the full union, `eslint . --no-inline-config`, at `74898ed5`: exit 0. ## First-party callers, measured - `@objectstack/client` sends `limit` as written: `data.listImportJobs`, `data.export`, `meta.getHistory`, `search`. - objectui at its pin `f8a9d0fb`: `ImportWizard` sends `limit: 50`, `ResourceHistoryPage` sends `limit: 100`, and the search and export emitters send `limit` only when it is a number greater than 0. None sends a value this PR refuses. ## Acceptance notes - **Same-family reads left out of this diff.** Triage's note 3 keeps them out, and the file is also held by another claim (PR objectstack-ai#20125). - Measured on the real routes with a spy protocol, each answering 200: - `GET /meta/:type/:name/history?sinceSeq=abc` drops `sinceSeq` and reads the log from the start (`rest-server.ts:8139`); - `GET /meta/:type/:name/audit?limit=abc` drops `limit`, so the producer's default 100 is served (`:8318`; declared `z.number()`, which refuses `NaN`); - `GET /search?perObject=abc` hands `perObject: NaN` to `searchAll` (`:10709`). - Read only, not measured: `GET /data/approvals/requests` `limit` / `offset` drop a non-numeric value and serve the unpaged list (`:13601`). Export `?page=` falls back to the 500-row chunk (`:10297`), which changes chunking only, not the rows returned. - **History's declared `limit` admits `1.5`, `0` and negatives.** The door forwards them as before. Whether `HistoryMetaItemRequestSchema.limit` should declare `int()` / `min()` is the spec seat's question, and this PR takes no position. - **Export and search range stay as they were:** the export floor of 1 and cap of 50000, and search's `[1, 100]` clamp. Both cards take no position on bounds. - **`GET /data/import/jobs?status=` is not checked against the declared `ImportJobStatus`.** Read only, not measured, out of scope. - **Merge:** `origin/main` was merged (no rebase) after PR objectstack-ai#20128 landed on `rest-server.ts`, with no conflict. PR objectstack-ai#20125's hunks do not overlap these doors. - **CI-owned, not run locally:** the full `Test Core` shards, `Dogfood`, `Build Core`, `Temporal Conformance`, and the workspace type-check lanes. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…any driver call — a string, number or Map `where` on a multi-row update or delete rewrote or removed every row (objectstack-ai#20144) Fixes objectstack-ai#20121 Clause-②: no (narrowing) ## Write verbs first — the base reading: a whole-table rewrite and a whole-table delete Measured on `origin/main` `a08e059c61` before any edit, through the real `ObjectQL`, four rows seeded (two per owner), on `driver-memory` and on `SqlDriver` (better-sqlite3 `:memory:`). The rows touched were read back past the engine. | call | no `SecurityPlugin` | `SecurityPlugin`, system context | `SecurityPlugin`, RLS-scoped member | |:--|:--|:--|:--| | `update(o, {name:'Z'}, { where: 'amount > 100', multi: true })` | **4 of 4 rewritten** (both drivers) | **4 of 4 rewritten** (both drivers) | refused by the driver, `INVALID_FILTER`, 0 rewritten | | same with `where: 42` / `where: new Map(...)` | **4 of 4** | **4 of 4** | refused by the driver, 0 | | `delete(o, { where: 'amount > 100', multi: true })` | **4 of 4 deleted** (both drivers) | **4 of 4 deleted** (both drivers) | refused by the driver, `INVALID_FILTER`, 0 deleted | | same with `where: 42` / `where: new Map(...)` | **4 of 4** | **4 of 4** | refused by the driver, 0 | | either write, `where: [1, 2, 3]` | refused, `code`/`status` undefined, 0 | same | same | | either write, same shapes, no `multi` | refused: "Update/Delete requires an ID or options.multi=true" (no code), 0 | same | same | | control: `where: { amount: { $gt: 100 } }` or `[['amount','>',100]]`, `multi: true` | 2 of 4 | 2 of 4 | 1 of 4 (the member's own row) | - **Path:** the multi-row path. `update()` and `delete()` lower at the `[objectstack-ai#5158]` comment ("Lower before the by-id extraction below reads `where.id`" / "Same ordering reason as update()"), then `resolveEngineUpdateDispatch` / `resolveEngineDeleteDispatch` look for a `where.id` only in an object `where`, find none, and return `{ kind: 'multi' }` on `options.multi`. The seeded AST carries the string, and the driver's `updateMany` / `deleteMany` ignores it. - **"Every row the caller can reach"** holds without `SecurityPlugin` and under a system context with it. For an RLS-scoped caller it depends on the value: - A string, number or `Map` (and a `Date`, `Set` or `true`) is AND-composed by the security middleware into `$and`, where the driver's node gate refuses it: 0 rows. - The empty string is NOT refused. The composition (`security-plugin.ts`, `opCtx.ast.where ? { $and: … } : …`) reads a falsy `where` as absent and drops it, so the member's `''` read all 2 of their rows, and `update(multi)` / `delete(multi)` rewrote or deleted 2 of 4 on both drivers: every row the member could reach. - **A second door stepped around, too:** the unscoped-write guard (`dispatchUnscopedMultiWriteHooks`, the `sys_attachment` / `sys_comment` predicate-less-write refusals) treats only an absent or `null` `where` as unscoped, so a string / number / `Map` `where` skipped that guard while the driver treated it as no predicate at all. The re-grade to p0 is the seat's act; this PR lands the refusal. ## What changed (read from the code at the head of this branch) `packages/objectql/src/engine.ts`, `lowerWhereFilterArray` only: 1. **A shape gate at the top of the seam.** A `where` that is not `undefined`, not `null`, not an array and not a filter object is refused with `invalidFilterError` (the package's existing ADR-0112 builder: `code: 'INVALID_FILTER'`, `status: 400`, `httpStatus: 400`). All six verbs (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`) call the seam before they resolve a driver, so the refusal comes before any driver call. The message uses the wire door's words: `VERB('OBJECT'): 'where' must be a filter object or condition array, received string "amount > 100". It was not applied, and an unapplied filter would have returned the unfiltered result set.` For `update` / `delete`, the last clause reads `updated every row in scope` / `deleted every row in scope`. 2. **The non-filter-array branch carries the same envelope.** Its message is unchanged; it now throws through `invalidFilterError` instead of a bare `Error`. 3. **The accept set** is decided by `isWhereFilterObject`: a non-array object whose built-in tag (`Object.prototype.toString`, which reads `Symbol.toStringTag` through the prototype chain) is `[object Object]`. That covers a plain object, `Object.create(null)`, a class instance, a `Proxy` of one, and an object from another realm. An object that overrides `Symbol.toStringTag`, own or inherited, is refused and named by its tag (H4 below). No new export, no new error code, no change to the drivers, to the REST door, to the `filter` alias or to the `having` region. The docblock hunk draft PR objectstack-ai#20125 edits is not touched. ## H4 — every `where` shape, base answer and head answer Measured on both drivers with no security; `find` / `update(multi)` / `delete(multi)` shown (count and aggregate follow `find`). | shape | base | head | |:--|:--|:--| | string, number, `Map` | dropped: 4 rows read / 4 rewritten / 4 deleted | `INVALID_FILTER` / 400, 0 driver calls | | non-filter array `[1,2,3]` | refused, `code` / `status` undefined | `INVALID_FILTER` / 400, 0 driver calls | | `Date`, `Set`, `true`, `''` | dropped: 4 / 4 / 4 | `INVALID_FILTER` / 400, 0 driver calls | | `undefined`, `null` | no filter: 4 / 4 / 4 (declared unscoped) | unchanged | | `{}`, `[]` | no filter: 4 / 4 / 4 | unchanged | | `Object.create(null)` with the filter | filters correctly: 2 / 2 / 2 | unchanged, accepted | | an author's class instance with the filter on own keys | filters correctly: 2 / 2 / 2 | unchanged, accepted | | an object overriding `Symbol.toStringTag` (own or inherited) with the filter on own keys | filters correctly: 2 / 2 / 2 (contract review's reading) | `INVALID_FILTER` / 400, `received Criteria` — the one correctly-answering shape this narrows; no producer in the repo creates one | - `null` stays accepted: `findOne`'s no-predicate guard and the unscoped-write detector both already read `null` as absent. - `''` is refused, although the wire door reads a blank `?filter=` as absent. At the engine, `''` deleted every row while the unscoped-write guard read it as scoped. Under RLS it was not refused either: the security middleware dropped it as absent, and the member's every reachable row (2 of 4) was rewritten or deleted. The wire door's normalizer deletes a blank `?filter=` before the engine sees it; other REST paths were not measured for `''`. - `findOne` keeps its own no-predicate refusal (no `code`) for `null` / `undefined` / `{}` / `[]`, exactly as at base. ## H3 — other doors that take a caller `where` (census, base and head, `driver-memory`, bad value `'amount > 100'`, control `{ amount: { $gt: 100 } }`) | door | on this seam? | base | head | |:--|:--|:--|:--| | `createContext().object(o).find` / `.delete({ multi: true })` | yes, forwards to the engine | 4 rows / 4 deleted | refused `INVALID_FILTER` / 400 (pinned) | | analytics auto-bridge (`engine.aggregate(o, { where: filter })`) | yes, by reading `service-analytics` `plugin.ts` | follows `aggregate` | follows `aggregate` | | `aggregate` `aggregations[].filter` | no, its own loop | string dropped: every group counts all rows | unchanged — Acceptance notes | | a `beforeFind` hook assigning `ctx.input.ast.where` | no, runs after the seam | 4 rows | unchanged — Acceptance notes | | a middleware assigning `opCtx.ast.where` | no, runs after the seam | 4 rows | unchanged — Acceptance notes | | a `beforeUpdate` / `beforeDelete` hook assigning `input.options.where` on `multi: true` | no | inert: the predicate path had already seeded its AST (2 rows, same as control) | unchanged | | `updateMany` / `deleteMany` / `upsert` / `distinct` on the engine | — | not engine verbs (`upsert` and `distinct` are retired) | — | ## Tests (at `aa923a1f5c`, the code head, unless noted) Head `2bd379d22a` changes only the changeset prose, after contract review 5831634153. `engine.ts` (`4f5d28170b`) and both pins are blob-identical to `aa923a1f5c`, so every test reading below stands for the head. - New unit pin `packages/objectql/src/engine-where-shape-refusal.test.ts`, 47 cases, all through a counting driver double: 6 verbs × 4 bad shapes, each asserting `code`, `status`, the `VERB('deal')` prefix, zero driver calls and an unwritten table; 6 × 2 controls; the scoped-repository door; five accepted edge shapes; five refused edge shapes on `delete(multi)`. Run together with `engine-filter-array-lowering.test.ts`: `Test Files 2 passed (2) · Tests 107 passed (107)`. - New dogfood pin `packages/qa/dogfood/test/engine-where-shape-refusal.test.ts`: `update(multi)` and `delete(multi)` × the 4 bad shapes on `SqlDriver` (better-sqlite3), reading the table through knex; plus two controls. Against the **base** `objectql/dist`: `8 failed | 2 passed` (the 6 string/number/`Map` cells: "expected the engine to refuse, and it answered"; the 2 array cells: `expected undefined to be 'INVALID_FILTER'`). After rebuilding `objectql`: `10 passed` (rerun at the head: `10 passed`). - `@objectstack/objectql` whole suite (both vitest projects), at `6485915e34`: `Test Files 317 passed (317) · Tests 5499 passed (5499)`. Between that commit and the head, only the new test file changed: one repository case was added, and the double now honours `limit` and refuses combinators. `engine.ts` did not change. - `@objectstack/plugin-security` whole suite, at `6485915e34` against the rebuilt `objectql` `dist/`: `Test Files 135 passed (135) · Tests 2685 passed (2685)`. - Dogfood tests that exercise an engine `where`, at `6485915e34`, a declared subset (CI's Dogfood Regression Gate runs all of them): the new pin, `attachments-unscoped-delete-gate`, `bulk-widener-probe`, `owner-anchor-and-bulk-writes`, `authored-row-write-scope`, `comments-permission-matrix`, `attachments-permission-matrix`, `owd-public-read-write-write-floor`, `hook-runas-fls`, `showcase-crud-persona-matrix`: `Test Files 10 passed (10) · Tests 131 passed | 1 skipped (132)`. The skip is the pre-existing `describe.skipIf(!organizationsAvailable)` in `attachments-permission-matrix`. - `pnpm --filter @objectstack/objectql run typecheck` green, including `check:test-typecheck` (ledger held, 40 files / 234 errors / 65 signatures); the new test file is in the `tsconfig.test.json` program (`--listFiles`). `pnpm --filter @objectstack/dogfood run typecheck` green. - `check-changeset-no-major --base a08e059`: no `major`. `check-adr-0087-registration --base a08e059`: 1 declared-breaking changeset, disposition `not-required (no-migration-prescription)`. **Ablation**, run at `544762f243` (the 46-case pin, before the repository case was added), both legs through `scripts/ablation-replace.mjs` (anchor hits verified on disk, restore proven by blob equality with `HEAD` and an empty `git diff HEAD`), with the fix committed first: 1. Shape gate disabled (the `if (where !== undefined && … !isWhereFilterObject(where))` line → `if (false)`): `23 failed | 23 passed (46)`. Exactly the 18 string/number/`Map` cells plus the 5 refused edge shapes turned red; the 6 array cells stayed green on the array branch. Predicted direction, observed. 2. The array branch's `invalidFilterError(` → `new Error(`: `6 failed | 40 passed (46)`, exactly the six non-filter-array cells. ## Gates - Derived from the real diff at `aa923a1f5c` with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (4 paths vs merge base `a08e059c6`): **68 commands, all exit 0**, captured before any pipe. `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`, a zero derived from the recorded exit codes. - The first pass at `c51cd3d02b` turned up two real reds, both in the new test double: `check:objectql-double-limit` (the double's `find` ignored the caller's `limit`) and `check:where-matcher` (its matcher read a combinator as a field name). Both are fixed in `aa923a1f5c`. The same pass had `check:dual-build-cjs-loads` at exit 3, `PREREQUISITE NOT MET` (eight unrelated packages had no `dist/`). That is not a red: those eight were built and the gate reran green (`105 published require entry point(s) across 67 package(s) load`). - `node scripts/check-issue-citations.mjs --base a08e059`: `every citation this change adds resolves` (6 judged). - Rerun at `2bd379d22a` after the prose patch: `check-changeset-no-major --event` over this body, `check-adr-0087-registration` and `check-issue-citations`, all exit 0 (quoted in the patch report). - Not run locally, and left to CI: the repo-wide `pnpm lint`, the full dogfood suite, `Build Core`, and `Temporal Conformance` (live PG and MySQL). ## Acceptance notes - **`aggregate`'s per-aggregation `filter` drops a string the same way `where` did.** Base and head, `driver-memory`: `aggregations: [{ function: 'count', field: 'id', alias: 'n', filter: 'amount > 100' }]` counts every row (control `{ amount: { $gt: 100 } }` counts one per group). `AggregationNodeSchema.filter` is declared `FilterConditionSchema`. The loop that walks it (`[objectstack-ai#10576]` in `aggregate()`) is a separate door from `lowerWhereFilterArray`, so it is outside this claim. No public door or real producer was measured: the wire door parses `aggregations` through `AggregationNodeSchema`, and the analytics `ObjectQLStrategy` produces objects. - **A hook or middleware that rewrites the where after the seam** (`ctx.input.ast.where` in `beforeFind`, `opCtx.ast.where` in a middleware) is not re-checked: a string there reads every row (census above). The written value is the hook's, not the caller's, and this seam runs before either. - **The RLS leg at base was refused by the driver, not by the engine, except for `''`.** For a string, number, `Map`, `Date`, `Set` or `true`, the security middleware wrapped the value into `$and`, where the driver's own node gate answers `INVALID_FILTER`. That refusal is now unreachable for those shapes, because the engine refuses first. The empty string was dropped as absent by that composition (`opCtx.ast.where ? … : …`) and reached every row the member could reach; the engine now refuses it too, before the middleware runs. - **Per-verb consequence clause.** The message's last clause differs by verb, because a write has no result set; the rest of the sentence is the wire door's. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…objectstack-ai#20155) Fixes objectstack-ai#20129 Clause-②: no ## What was wrong `GET /api/v1/meta/doc/:name` and `GET /api/v1/meta/doc` decide "may this caller read the doc" (ADR-0046 §6.7) from two reads besides the doc itself: the environment's books, and on the single read the doc corpus those books claim over. Both reads mapped a thrown read to `[]`: - `fetchAudienceBooks` answered `[]` on a fault. To the resolver that is "no `{ permissionSet }` book anywhere", so an authenticated caller takes the fast path (`allReadable`) and every doc is readable. - The single read's corpus read answered `[]` on a fault. To the resolver that is "no book claims this doc", so its audience is `org`. Measured on `16c5a33fdd`, before this change, driving the real handlers with a protocol double whose list read rejects. The caller is authenticated and does not hold `crm_admin`: | fault | `GET /meta/doc/crm_admin_runbook` | `GET /meta/doc` | |---|---|---| | none (control) | 403 `PERMISSION_DENIED` | `crm_intro` only | | book read rejects | **200 with the gated body** | **lists `crm_admin_runbook`** | | corpus read rejects | **200 with the gated body** | (the list's own doc read, 503) | ## The fix The fix stays inside the existing `readAudienceBooks` / `readDocCorpus` path. It adds no second resolver (ruling `5793362670`, item 1) and no new error code. - `fetchAudienceBooks` now throws the read's own fault instead of answering `[]`. Its two callers are the `/meta/doc` list filter and the `/meta/doc/:name` gate. - On the `/meta/doc/:name` gate, a corpus-read fault now throws the same way. - Both faults reach the route's existing `handleRouteError`. The comments this change made false are corrected: the `readMetaList` doc and the app-nav arm's "Fails CLOSED" paragraph. ## The one open judgment: what the doc reads answer on a fault The route suggested pruning. I chose the sibling's answer instead, which is to propagate the fault. Sibling reading on `16c5a33fdd`, same double: | door | book-read fault, `metadata-protocol` shape (503) | book-read fault, untyped throw | |---|---|---| | `GET /meta/book/:name/tree` (bare `await`, always propagated) | 503 `SERVICE_UNAVAILABLE` | 500 `INTERNAL_ERROR` | | `GET /meta/doc` with its OWN doc read failing | 503 `SERVICE_UNAVAILABLE` | 500 `INTERNAL_ERROR` | After this change, `GET /meta/doc/:name` and `GET /meta/doc` answer a book-read or corpus-read fault the same way. One fault gets one answer across the docs doors, pinned by comparing each door's `status` + `code` against the tree door's. Both codes are already in the ADR-0112 standard catalog. - **Not 403 `PERMISSION_DENIED`** (triage's suggested pin). That code states an authorization verdict the server never reached: it would tell a holder they hold nothing, and it makes an outage indistinguishable from a denial (ADR-0110 D3's miss-vs-outage rule, one layer up). - **Not "prune" on the list.** Without the books the gate can clear no doc at all: any doc may be claimed by a set-gated book, and `public` only comes from a book. So the pruned list is empty for every caller, which is an invented "this environment has no docs" during an outage. The app-nav arm prunes because a nav response is a composite and the rest of it is still served; a doc list has no rest. - **Logging:** the fault is handed to the caller, so per AGENTS.md "Degradation log levels" no extra `logger.error` is added. `handleRouteError` already logs the withheld 5xx. The cost, stated: while the book store is failing, no doc is served or listed, not even an `org` doc, and not even to a holder. An anonymous caller with a book fault now gets the fault (503) instead of 401, which is also what the tree door has always answered it. Healthy reads are byte-for-byte unchanged. ## Reach with the shipped protocol `metadata-protocol`'s `getMetaItems` throws `metadataStoreUnavailableError` (503 / `SERVICE_UNAVAILABLE`) for every `sys_metadata` read failure except "unprovisioned" (`isMissingTableError`). This is pinned in its `protocol.metadata-store-outage.test.ts` ("the PLURAL read stops answering ..."). A marked `beforeFind` refusal from a sandboxed `sys_metadata` hook travels its own refusal category (`metadataReadFailureError`). Either way the book read throws after the doc read succeeded, which is the path this change closes. ## Tests New file: `packages/rest/src/meta-doc-audience-read-fault.test.ts`, 12 cases. It drives the real route handlers and asserts `status` + `code` (ADR-0112 minimum), never the prose. - **Book read rejects** (both fault shapes): the set-gated doc is not served to a non-holder (no 200, no `item`, the body text absent from the wire) and is not listed. The envelope equals the tree door's for the same fault. - **One book-read fault, three doors, one answer:** the doc read, the doc list and the book tree all answer 503 `SERVICE_UNAVAILABLE`. - **No doc is cleared without the books:** an `org` doc, and a holder, are not served during a book fault. - **Corpus read rejects** on the single read (both fault shapes): the doc is not served, and the answer equals the tree door's. The test asserts that the book read and the corpus read were each attempted once, so the gate really reached the corpus read. - **Controls, healthy reads:** 403 `PERMISSION_DENIED` plus absence from the list for the non-holder, 200 with the body plus presence in the list for the holder, and 401 `UNAUTHENTICATED` for anonymous. ## Ablation The fix was committed first (`955d5a5c`). Each leg ran through `scripts/ablation-replace.mjs`: the anchor must hit, and the landing is proven on disk. The test imports `./rest-server.js` relatively, so the mutation is read from `src/` and no `dist/` rebuild is involved. | leg | mutation | predicted | observed | restore | |---|---|---|---|---| | A1 | `fetchAudienceBooks` back to `return 'fault' in read ? [] : read.items;` | 6 red, the book-fault cases | **6 failed / 6 passed**, exactly the book-fault cases | blob `ee61f7664c92` == HEAD, `git diff HEAD` empty | | A2 | corpus read back to `docReader('fault' in read ? [] : read.items)` | 3 red, the corpus-fault cases | **3 failed / 9 passed**, exactly the corpus-fault cases | blob `ee61f7664c92` == HEAD, `git diff HEAD` empty | Both went red in the predicted direction, with no inversions. ## Verification (HEAD `955d5a5c`) - `pnpm --filter @objectstack/rest test` (the `local` project): 197 files passed, 3339 tests passed, 1 skipped. `test:repo`: 1 file, 8 tests passed. - `pnpm --filter @objectstack/rest typecheck`: exit 0. The new test is in the `tsconfig.test.json` program (`--listFiles` count 1). - `pnpm lint` (full repo): exit 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands off the merge base. 59 exited 0. 2 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET), both needing a whole-workspace build this container does not hold: - `check:dual-build-cjs-loads`. A narrowed probe did load `packages/rest/dist/index.cjs` with exit 0. - `check:type-check-debt`. `@objectstack/rest` is not a ledgered package, and this diff changes no exported type. - `check-plugin-teardown-shape --self-test` first exited 3 because the shallow clone lacked its pinned fixture commit. It exited 0 after fetching that one commit. - `--ran` reconciliation: 61 accounted, 0 UNRUN. ## Changeset `.changeset/20129-docs-audience-fail-closed.md`, **patch** for `@objectstack/rest`. This is a security bug fix in a released package (AGENTS.md Post-Task Checklist 3). It adds no published surface: only private methods change, the wire codes are ones these routes already emit, and healthy-read answers are unchanged. ## Scope - objectstack-ai#20130 remains open (the `/meta/app/:name` side doors). objectstack-ai#20139 remains open (the bare `Number()` query reads). Neither is touched here. - PR objectstack-ai#20125 landed on `main` as `8d1f7ab7` after this branch's base. `git merge-tree --write-tree origin/main HEAD` is clean, and none of its hunks are touched. - **Out-of-scope finding, handed to the seat for filing, not fixed here:** `GET /meta/doc/:name/layers`, the deprecated `GET /meta/doc/:name?layers=true` and `GET /meta/doc/:name/published` serve the set-gated doc body to an authenticated non-holder with healthy reads. No §6.7 gate runs on those doors at all. This is the same "side door skips the per-caller read gate" family as objectstack-ai#20130, but for a different reason than this card (a gate that is absent, not a fault read as absent). ## Acceptance notes - `GET /meta/doc/:name/history` and `/diff` were not measured for the same side-door question. - No hand-written `content/docs/**` line is made false: `content/docs/ui/doc-pages.mdx` states no fault behaviour. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ Co-authored-by: Claude <noreply@anthropic.com>
…ing on apiEnabled: false, export only where the export door admits it (objectstack-ai#20135) (objectstack-ai#20151) Fixes objectstack-ai#20135 Clause-②: no `apiOperations`, the operation set `/auth/me/permissions` (and `ISecurityService.getEffectiveObjectPermissions`) attaches to each entry, now offers exactly what the REST door serves this subject. It used to disagree with the door in two places, both in the direction of offering an operation the door refuses: - **`enable.apiEnabled: false`.** The door answers `404 OBJECT_API_DISABLED` for every verb. The annotation ignored the switch: it carried the object's whole closure, or no annotation at all (read by a client as default-allow) when the subject's export stays allowed on an otherwise unrestricted object. - **The export slot** (seat pointer 5831905897). `annotateEffectiveApiOperations` fell back to the MERGED `'*'` export bit (`acc.allowExport ?? wildExport`). So a private object reached only through a plain `'*': { allowExport: true }` was annotated `export`, and so was an object the exporting set itself names without the grant. The export door answers both `403 EXPORT_NOT_PERMITTED`. The fix is in the producer, `annotateEffectiveApiOperations` in `@objectstack/core` (`packages/core/src/security/effective-object-permissions.ts`), as triage directed: 「read the same resolver `enforceApiAccess` reads, so the two cannot diverge」. The REST door, `packages/spec` (`resolveEffectiveApiMethods`, `effectiveOperationsArray`, `apiExposureDenialReason`), `checkObjectPermission`, the seed and the folds are all untouched. No exported name or type changes. ## What changes The annotation asks the door's own two questions, entry by entry: - **The object half** is `canServeApiOperation` (`@objectstack/spec/data`). It is the boolean face of `apiExposureDenialReason`, the function `apiAccessDenialFromEnable` (`enforceApiAccess`) turns into its 404 and 405. The served set is the closure filtered through it. It judges `apiEnabled === false` first and for every operation, so an API-disabled object is annotated `[]`. For any other `enable` the filter is the identity, because the closure already is what the door admits. - **The export half** is `objectPermissionGrants(entry, 'allowExport')`: read and `allowExport` on the entry itself. That is the export door's conjunction, and `PermissionEvaluator`'s `export` branch documents it as what the merged per-object entry answers. For the export bit, since objectstack-ai#20083 and objectstack-ai#20134 the coverage passes put each set's `'*'` grant only on the entries that set reaches, per posture, so the entry's `allowExport` already is the per-set, per-posture answer and the merged-bit fallback is gone. The read half of the conjunction is the entry's read bit, which still carries the merged fold's read over-grant pre-registered for objectstack-ai#20136. It moved no cell of the parity column below. Which entries carry an annotation keeps the objectstack-ai#18931 / objectstack-ai#18990 rule: an unrestricted object whose every operation is still served, `export` included, gets none. What moved is the evaluation of "still served": an API-disabled object never qualifies. ## H4: `apiOperations: []`, and the entry stays - **What the client reads.** Nothing in this repo's SDK reads `apiOperations`. `git grep apiOperations -- packages/client packages/client-react` finds 0 lines; `@objectstack/client` only re-exports the `GetEffectivePermissionsResponse` type. The reader is objectui, which is **UNMEASURED** here because the sibling repo is not in this container. From the spec contract (`EffectiveObjectPermissionSchema.apiOperations`: "absent = default-allow") and the objectui code quoted on objectstack-ai#18931 (`effectiveApiOps ? effectiveApiOps.includes('export') : true`), an array hides every operation it does not list and an absent one hides nothing. `[]` is also the shape a deny-all (`apiMethods: []`) object already carries, so the client meets no new shape. - **Why not drop the entry.** The entry carries the CRUD bits that `current_user.can()` reads on the server, and it reads an absent entry as "no grant". `apiEnabled` closes the API, not data access (`enforceApiAccess`' docblock: "`apiEnabled` controls automatic API exposure, not data access"). Dropping the entry would change a server-side `can()` answer, which H5 rules out, and would contradict the objectstack-ai#18990 ruling that every reachable object gets an entry. ## Measurement **Door semantics used below.** An operation is "served" when neither door refuses it: the object door (`404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`) and, for `export`, the export door (`403 EXPORT_NOT_PERMITTED`). The operations measured are the ones the REST door gates by name: `get`, `list`, `create`, `update`, `delete`, `bulk`, `import`, `export`. `bulk` counts as served when any of `createMany` / `updateMany` / `deleteMany` passes the door. "Offered" means the entry's `apiOperations` lists the operation, or the entry has no annotation (default-allow). ### Real stack (H1, H2): the real `GET /auth/me/permissions` against real REST requests This used a throwaway dogfood probe that is **not committed**. It ran `bootStack` with the real `SecurityPlugin`, `orgContext: true`, 61 registered objects, and one authored app. The app holds `pq_open` (public), `pq_exposed` (`apiEnabled: true`), `pq_hidden` (`apiEnabled: false`), `pq_hidden_subset` (`apiEnabled: false` + `apiMethods: ['get','list']`), `pq_subset` (`apiMethods: ['get','list']`), `pq_private` (`access.default: 'private'`) and `pq_private_hidden` (private + `apiEnabled: false`). There are 0 such `apiEnabled: false` objects in `examples/`, so the reach is authored objects only. Every subject × every registered object × the 10 door requests above went through the real REST routes, with bodies that cannot mutate. Base is `16c5a33fdd`; head is this branch. | subject (resolved sets) | offered but refused, present entries: base → head | served but hidden: base → head | no-entry cells (unchanged) | route == member | |---|---|---|---|---| | seeded platform admin (`admin_full_access`, `organization_admin_no_bypass`, `member_default`) | 16 → **0** | 0 → 0 | 0 | yes | | `admin_full_access` + `member_default` + a plain `'*': { allowExport }` | 21 → **0** | 0 → 0 | 0 | yes | | `member_default` | 0 → 0 | 0 → 0 | 134 | yes | | member + explicit read on the app objects | 16 → **0** | 0 → 0 | 101 | yes | | … + a plain `'*': { allowExport }` | 20 → **0** | 0 → 0 | 101 | yes | | member + explicit read and export on the app objects | 19 → **0** | 0 → 0 | 101 | yes | | member + a plain `'*'` with every bit and export | 11 → **0** | 0 → 0 | 15 | yes | | member + `'*': { viewAllRecords, allowExport }` | 19 → **0** | 0 → 0 | 0 | yes | - **H1 holds.** At base, `pq_hidden` read `[get, list, create, update, delete, upsert, bulk, aggregate, search, import]` for the platform admin, and had no annotation at all for export-allowed subjects. Meanwhile every REST verb on it answered `404 OBJECT_API_DISABLED`. At head every API-disabled object reads `[]`. - **H2 holds.** For `admin_full_access` beside a plain export-only `'*'`, `sys_secret` read `[get, list, aggregate, search, export]` and `GET /data/sys_secret/export` answered `403 EXPORT_NOT_PERMITTED`. The same holds for the authored private `pq_private`, which had no annotation (default-allow) and a 403 export. The member with explicit read beside the plain export wildcard shows the same on `pq_private`. - **Entry-level diff, base-equivalent leg → head, all 8 subjects** (the base-equivalent leg is the ablation below; its door readings equal true base in all 8 subjects): 0 entries added or removed, 0 `allow*` bits changed, **0 operations added to any annotation**, 74 operations removed, 11 annotations added (9 × `[]`, 2 × the closure minus `export` on `pq_private`). - **No-entry cells** are objects the subject's map carries no entry for (e.g. `member_default` on the `sys_*` objects it cannot read). Whether an entry exists is the seed's question, and this PR leaves it alone. See the Acceptance notes. ### Unit fixture (H3): the committed parity column `plugin-security`'s `get-effective-object-permissions.test.ts` table covers PR objectstack-ai#20145's 16 subjects plus 2 export-slot subjects. The 2 new subjects are `admin_full_access` beside a plain export-only `'*'`, and one set whose exporting `'*'` sits beside an explicit `crm_lead` entry without the grant. Each subject is checked against every registered object its map carries (the 11-object `REGISTERED` fixture) and every door-gated operation. The oracle is `canServeApiOperation` for the object half, the door's decision function, since plugin-security takes no dependency on `@objectstack/rest`. For `export` it is the real registered `svc.canExport`. The subjects also run through the existing `can()` ↔ `checkObjectPermission` rows, green with `KNOWN_OVER_GRANT` untouched. | subject | offered but refused: base → head | served but hidden: base → head | |---|---|---| | wall-less org admin · viewer · one set: plain `'*'` + narrower entry · platform admin · platform admin + wall-less · walled org admin · bare modify-all · super-read + plain bits · one set: super-user `'*'` + narrower entry | 7 → **0** each (`crm_hidden` × 7) | 0 → 0 | | explicit entry beside a plain `'*'` · super-user `'*'` + export · super-read `'*'` + export · explicit entry beside a super-user `'*'` | 8 → **0** each (`crm_hidden` × 8, `export` included) | 0 → 0 | | **platform admin beside a plain export-only `'*'`** | 10 → **0** (`crm_hidden` × 8, `crm_secret.export`, `sys_secret.export`) | 0 → 0 | | **one set: exporting `'*'` + explicit `crm_lead` without the grant** | 9 → **0** (`crm_hidden` × 8, `crm_lead.export`) | 0 → 0 | | member · export-only `'*'` beside a reader · a `'*'` granting nothing | 0 → 0 | 0 → 0 | Entry-level over these 18 subjects: 0 entries added or removed, 0 `allow*` bits changed, 0 operations added to any annotation, 92 removed, 7 annotations added. The core pins also hold an export grant without read to no `export`, and read and export arriving from two different sets to `export`. ## For `domain:cli` (the route) and `domain:services` (plugin-security): cross-lane No code in `plugin-hono-server` or `plugin-security` changes, only their pins. The bytes `/auth/me/permissions` serves change for the affected objects, and `getEffectiveObjectPermissions` returns the same map: - an object declaring `enable.apiEnabled: false` reads `apiOperations: []` in every entry. That includes an unrestricted one whose export stays allowed, which used to carry no annotation; - `export` leaves the annotation where the export door refuses it: a private object reached only through a plain wildcard export grant, an object named without the grant by the set whose wildcard carries it, and an entry granting export without read. A private, unrestricted object in that position gains an annotation, its closure minus `export`. Nothing else moves: no entry, no `allow*` bit, no added operation. Shape, keys and route are unchanged, and so is every server decision. `can()` reads the same bits. ## Clause-② `no`, as the claim carries it. The served annotation narrows only toward what the door already refuses (0 operations added, measured above), and the door is untouched, so no request that succeeded now fails. No exported name or type changes. `annotateEffectiveApiOperations` keeps its signature. As an exported helper called standalone, it no longer reads the map's `'*'`, a behaviour change named in the changeset. There are 0 non-test callers in this repo: every hit of `git grep annotateEffectiveApiOperations` outside tests is its definition, its re-export, the composition and comments. `check-changeset-no-major --base origin/main` exits 0 ("This diff introduces no `major` bump"), and `check-adr-0087-registration --base origin/main` exits 0 ("1 non-breaking changeset(s) seen"). The changeset grades `@objectstack/core` `patch`. ## Tests run, at head `f2904cd05b` (core `dist/` rebuilt from this source) Each suite ran under the verify lock, and each line quotes its `VERDICT command-exit 0`: - `pnpm --filter @objectstack/core test`: 53 files, 1353 tests passed. `typecheck` exit 0, test layer included. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 323 passed. `typecheck` exit 0. - `pnpm --filter @objectstack/plugin-security test`: 135 files, 2718 passed. `typecheck` exit 0. - Dogfood, 18 files, against the `dist/` closure built from this code (`turbo run build --filter='@objectstack/dogfood^...'`, 64 tasks), in two runs: - 126 passed and 1 skipped: `organization-update-door`, `me-apps-and-everyone-baseline`, `showcase-permission-projection`, `showcase-permission-seeding`, `showcase-permission-zoo`, `two-doors-permission`, `comments-permission-matrix`, `attachments-permission-matrix`, `authz-conformance`; - 135 passed: `showcase-private-owd`, `owner-anchor-and-bulk-writes`, `showcase-crud-persona-matrix`, `showcase-client-liaison-fixtures`, `showcase-fls-read-mask-strip`, `showcase-scope-depth`, `showcase-scope-depth-write`, `showcase-scope-depth-fallback`, `showcase-anonymous-deny-surfaces`. - New and changed pins: - core `effective-object-permissions.test.ts`: 7 new cases in a `[objectstack-ai#20135]` block; - plugin-security `get-effective-object-permissions.test.ts`: 2 subjects, the `apiOperations` column (one case per subject) and a reported-cases case; - plugin-hono-server `current-user-endpoints-effective-objects.test.ts`: a route byte-equality case over an API-disabled and a private object; - plugin-hono-server `effective-api-operations.test.ts`: 4 API-disabled cases. Four existing cases were rewritten: one is **inverted on purpose**, since it pinned the merged-`'*'` fallback itself, and three called annotate on a map that never went through the per-set passes. Those three now go through `buildEffectiveObjectPermissions` and assert the same outcomes. ## Ablation The mutation restored the base reading in one block: the merged-`'*'` export bit back, and the door filter off. A string marker kept the dist reading exact. - It went through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `da81b319` → `33501e91`). Core's `dist/` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - **Observed direction: red.** - core: 5 failed, 22 passed. The two right-direction controls stayed green. - plugin-hono-server: 6 failed, 27 passed, over its two pin files together (`effective-api-operations.test.ts`, 29 cases, and `current-user-endpoints-effective-objects.test.ts`, 4 cases). - plugin-security: 16 failed, 36 passed. These are the 15 subject rows with an API-disabled or export mismatch, and the reported-cases case. The three subjects with no mismatch stayed green, as did every `can()` row. - The stack probe on the ablated build reproduced true base's door readings in all 8 subjects. - **Restore.** The blob equals HEAD, and `git diff HEAD` is empty (the tool's own verdict). Core was rebuilt, and `ablation-dist-preflight --absent` reports the marker absent from all 14 built files and a clean tree. All three suites are green again: 27, 33 and 52 passed. A second ablation leg, run to dump both legs' maps for the tables above, restored the same way. ## Gates, at head `f2904cd05b` - The list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 commands, derived from 6 paths against merge base `16c5a33fd`. I ran each one and recorded its exit code. **64 of 64 exit 0.** - One needed a second run. `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3, not measured) because 8 packages had no `dist/`. After `turbo run build` of those 8, all cache hits, it answered: "105 published require entry point(s) across 67 package(s) load; 660 emitted CommonJS file(s) parse". - `dispatch-gates --ran`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". - `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, 3 citations resolve. - **Lint, as a proven narrowing.** `eslint --no-inline-config --format json` over the 5 changed TypeScript files reports 5 files, 0 errors and 0 warnings. `eslint --print-config` resolves a config for each of them, and eslint ignores the changeset. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules, as its own comment states), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. - **NOT MEASURED locally** (CI owns them): Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, the type-check lanes, and the families `dispatch-gates` names outside its 64. ## DELIBERATE CORRECTION: three pending changesets (seat amendment 2, 5851881633) Each change rewrites ONE sentence of a pending release note that this PR makes false, following the objectstack-ai#20132 / objectstack-ai#20145 precedent. Every other line of each file is byte-identical: `git diff --numstat f2904cd HEAD` reads `1 1` on each file, and with the changed line deleted from both sides `diff` exits 0. Check Changeset turns red on the foreign-changeset rule by design, naming exactly these three files. ### 1. `.changeset/18931-me-permissions-unrestricted-export-annotation.md`, line 13 (blob `6eac3716` → `d49f56e3`) - **Before:** 「an object that is unrestricted **and** keeps `export` gets none.」 - **After:** 「an object that is unrestricted **and** keeps `export` gets none — unless its `enable.apiEnabled` is `false`, which is annotated `[]` since objectstack-ai#20135 because the REST door answers 404 for every verb on it.」 ### 2. `.changeset/18990-viewall-only-permissions-seed.md`, line 12 (blob `261e412f` → `727fbffa`) - **Before:** 「… an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134), because for it the client's default-allow path is already right.」 - **After:** 「… an unrestricted object whose export stays allowed still gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134), because for it the client's default-allow path is already right — except an object with `enable.apiEnabled: false`, annotated `[]` since objectstack-ai#20135 because the REST door refuses every verb on it.」 ### 3. `.changeset/20134-super-user-entries-every-bit.md`, line 15 (blob `a113da40` → `d2ee6a58`) - **Before:** 「That entry carries no `apiOperations`, exactly as the operation channel said nothing about the object before, so a client's default-allow path for the operation set is unchanged.」 - **After:** 「That entry carries no `apiOperations` — unless the object declares `enable.apiEnabled: false`, which is annotated `[]` since objectstack-ai#20135 — so for every other such object the operation channel says what it said before and a client's default-allow path is unchanged.」 **The new sentences are TRUE at this head.** An object with `enable.apiEnabled: false` is annotated `[]` in every entry, and the REST door answers `404 OBJECT_API_DISABLED` for every verb on it; both were measured on the stack above. Every other unrestricted object whose export stays allowed still carries no annotation. That holds for the controls `pq_open` and `pq_exposed`, and for `crm_account` in the core pins. **This card's own changeset follows.** Its line 21 said those notes "otherwise describe [such an object] as carrying none", which the corrections make false. That one sentence now reads 「That includes an unrestricted object whose export stays allowed, which used to carry no annotation at all; this release's notes for objectstack-ai#18931, objectstack-ai#18990 and objectstack-ai#20134 name that exception.」 `git diff --numstat` reads `1 1`, and every other line is byte-identical. ## Patch round 1, at head `ba66a482b2` (contract review 5851878434, prose only) Two commits sit on top of `f2904cd05b`: the three corrections, then this card's changeset sentence. They touch only those four `.changeset` files. Every code and test blob is identical to `f2904cd05b`: `git diff --stat f2904cd HEAD -- . ':(exclude).changeset'` is empty, and the five files carry blobs `da81b3192b`, `cd0367c631`, `ab113b718a`, `070606df33` and `0ea90cbfed` at both heads. So the suites, ablation, stack and gate readings above stand for the code. The branch was not merged with `main`, and its merge base is still `16c5a33fdd`. Gates re-run at `ba66a482b2`, with `--base 16c5a33` (the merge base) and this body as the `--event`: - `node scripts/check-empty-changeset.mjs --base 16c5a33`: **exit 1, as expected**. It names exactly the three corrected files, each as "present on the merge base and CHANGED by this PR -- this is somebody else's release note": `.changeset/18931-me-permissions-unrestricted-export-annotation.md`, `.changeset/18990-viewall-only-permissions-seed.md` and `.changeset/20134-super-user-entries-every-bit.md`. Its first line is "✓ No empty-frontmatter changeset introduced by this diff (4 declaring changeset(s) added)". The class is DELIBERATE CORRECTION, not COLLISION, so the base text must **not** be restored; a same-head contract-review PASS is what confirms it. - `node scripts/check-changeset-no-major.mjs --base 16c5a33 --event` (this body): exit 0. It prints "✓ This diff introduces no `major` bump." and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface.", with declaration line `Clause-②: no` and no direction arm. - `node scripts/check-adr-0087-registration.mjs --base 16c5a33 --event` (this body): exit 0, "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (4 non-breaking changeset(s) seen)." - `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, "✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference)." (3 judged, 3 resolve). - `origin/main` has since moved one commit, to `8d1f7ab785` (objectstack-ai#20125). It touches none of the six files in this diff, and in `packages/core/src/security` it touches only `operation-private-keys.ts` and its pin. The branch was not merged, as the seat directed. ## Acceptance notes - **Entry presence is untouched, and it has its own mismatches.** On the stack, a subject whose map has no entry for an object (the "no-entry cells" column) leaves the client on default-allow for that object's operations while the door refuses some of them. Two examples: `member_default` on `sys_metadata` `create`, and `export` on objects it cannot read. Those counts are identical at base and head. Which entries exist is the seed's and the folds' question: it is excluded from this claim, and objectstack-ai#20136 is next in this file. It is not filed here. `can()` reads such an object as "no grant" on every verb, which is the entry-presence ruling's territory (objectstack-ai#18990). - **Pending release notes, corrected in this PR.** Seat amendment 2 (5851881633 on objectstack-ai#20135) ruled Q1 → B after contract review 5851878434. The review measured `resolveEffectiveApiMethods({ apiEnabled: false }).mode === 'unrestricted'`, so an API-disabled object with no `apiMethods` is "unrestricted" in the resolver's own vocabulary, and all three pending sentences read false for it at this head. Each gets a one-clause DELIBERATE CORRECTION; see the section below. - **Spec describe string.** `EffectiveObjectPermissionSchema.apiOperations`' `.describe()` says "Present only when the object tightens exposure via apiMethods". That has been inexact since the export axis (objectstack-ai#3544), and is now inexact for `apiEnabled: false` too. `packages/spec` is out of scope, so this is noted only. - **`history` / `restore` / `purge` / `search` are outside the parity column** by construction. No REST route gates them by name. The spec's `apiExposureDenialReason` admits every operation on an unrestricted object, while `resolveEffectiveApiMethods` withholds the flag-gated ones, and that difference lives in `packages/spec`. It is unchanged, and the annotation keeps the closure's answer. - The stack probe and the ablation dump were throwaway files and are not in this diff. The tree was proven clean against HEAD after both. Session `session_01Bvd69VPa6puiNzzPUroDBx` (the `domain:engine` seat 1 dispatch, round 22), on branch `claude/issue-20135-api-operations-parity`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e SIZE limb, as it lifts a Tier H path (objectstack-ai#20159) Fixes objectstack-ai#20153 Clause-②: no ## What this lands The maintainer's ruling of 2026-09-27 (card objectstack-ai#20153, verbatim, untranslated): 「所以阈值写死成 5000 行 , 维护者已经批准了就是可以合并。」 **An authorized APPROVED review now lifts the SIZE limb exactly as it lifts a Tier H path.** "Authorized" is an account in `GOVERNED_APPROVERS`; the predicate is the governed leg's own — latest-decisive review per account, on ANY commit (the 2026-09-04 unpinning), dismissed / superseded / unauthorized approvals never count, an unreadable review list fails closed. After the approval the owning seat lands the PR through the queue; the maintainer's direct merge stays the other landing. Unchanged, as the card requires: the threshold (5,000), the strict comparison, "generated files included", the FORK limb (no approval lifts a fork), the post-merge sweep (it still lists every oversized landing — it hands the predicate no approval), `GOVERNED_APPROVERS`, the Tier S rule, and the exit-code register (no new code). ### Mechanism — one derivation, one predicate, imported - `scripts/pm/check-governed-merges.mjs` (the predicate's home): `testVerdict(paths, { size, approval })` takes the approval verdict as DATA; `sizeVerdict(size, approval)` adds `lifted` (approvers, the commit each approval was given on, the ruling's citation) and `approvalState`; `sizeLiftFrom` reads only the verdict's `state` and its `approvals` field; `sizeLimbFires(size)` = over the line AND not lifted; `landsByHumanMerge` reads it. `exceeds` still reads true over the line — the lift is a second fact beside the number, never a change to the number. - `scripts/pm/check-governed-queue-guard.mjs` (the queue leg): the SIZE leg REUSES the governed leg's `authorizedApprovalVerdict` object for a pull request that leg already read, else runs the same derivation on one review read of its own — only for a pull request that is OVER the line (a within PR and an unreadable size make no review read; both pinned with throwing spies). `authorizedApprovalVerdict` now also returns `approvals: [{ login, commitId }]` — the field only the authorized reduction carries, so the generic `approvalVerdict` (which never asked WHO) cannot lift. The guard spells no lift condition of its own; a self-test pin reads its source to prove it. - Exit codes: lifted → 0 (the SIZE block prints approver, commit, and `objectstack#20153` with the ruling verbatim); no / unauthorized / dismissed / superseded approval → 8, as today; unreadable size → 9, as today; **an unreadable review list on an oversized PR → 8** (over the line, no lift proven; the words say the list could not be read). Code 9 stays "the SIZE could not be read". Precedence governed-then-size is unchanged. - The seat-side `check-governed-merges.mjs --pr N` reads no reviews (it never did, for the PATH limb either, and it cannot import the guard's derivation — the module cycle is measured in the file). It answers the SIZE question the way it answers the PATH question: over the line is exit 3 and the words name the two landings the queue leg then holds the PR to. On the same PR the two tools read one predicate; the queue additionally holds the approval record — the same relationship they have on a Tier H path today. ### PM mechanism assumptions — what the tree said - Assumption 3 (the authorized-approval derivation is exported by `check-governed-merges.mjs` and imported by the guard): **falsified**. `authorizedApprovalVerdict` and `GOVERNED_APPROVERS` live in the guard; the guard imports the sibling at module scope, and the reverse edge (static or lazy) deadlocks (`Detected unsettled top-level await`, measured in both files' headers). There is still exactly ONE derivation — it stays in the guard; the sibling receives its result as data and re-derives nothing. No second copy was added. - Assumption 4 (protocol text): `AGENTS.md` class (c) stated the old rule and is rewritten (same three-line block, +1 line; ratchet ceiling 1116, now 1106). `.claude/skills/pm-dispatch/SKILL.md:187` (「改动超 5000 行(含生成物)同换终局四件套」) and `references/landing-operations.md:58` (「超 5000 行(含生成物)照 Tier H」) already route an oversized PR to the Tier H terminal, whose two landings line 189 / line 60 already spell (「授权批准 ⇒ 席位落地」, 「获授权批准后认领席落地」) — consistent, untouched, so no `.claude/**` file changes in this PR. `scripts/pm/dispatch-gates.mjs`'s dispatch-time line said "lands only by a HUMAN MERGE" and is rewritten (its self-test pin updated). - Assumption 5 (`--pr 20125` as a reading): NOT MEASURED from this container — the changed-files walk answered HTTP 403 on page 2 and the tool refused rather than answering on a subset (its documented behaviour). By construction it would answer exit 3 with the words naming both landings, and the queue leg would answer 0 on `os-zhuang`'s approval on `e4ead748` (the self-test replays exactly that shape). - Assumption 8 (unreadable review list): landed as exit 8, pinned in both scripts. ### Grep table — `5,000` / `5000` / `HUMAN_MERGE_LINE_THRESHOLD` / `size limb` over `scripts/pm/**`, `AGENTS.md`, `.claude/**` at `8d1f7ab7` | file | verdict | note | |---|---|---| | `scripts/pm/check-governed-merges.mjs` | changed | predicate, header, words, 13 new pins (battery floor 30 → 44) | | `scripts/pm/check-governed-queue-guard.mjs` | changed | SIZE leg, header, words, 13 new pins + 3 rewritten (battery floor 30 → 54) | | `scripts/pm/dispatch-gates.mjs` | changed | `changedLineLines` OVER text stated "lands only by a HUMAN MERGE"; its pin updated | | `AGENTS.md` | changed | Multi-agent §7 class (c) stated "lands only by a human merge" | | `.claude/skills/pm-dispatch/SKILL.md` | consistent | line 187 routes an oversized PR to the 四件套 terminal; line 189 names its two landings | | `.claude/skills/pm-dispatch/references/landing-operations.md` | consistent | line 58 「照 Tier H」; line 60 names Tier H's two landings | | `scripts/pm/check-half-states.mjs` | consistent | "a human merge or an authorized approval is the review record"; other hits are rate-limit numbers | | `scripts/pm/check-skill-line-ratchet.mjs` | unrelated | a ceiling-ledger comment narrating the 2026-09-18 raise (history, not a rule statement) | | `scripts/pm/check-prior-rulings.mjs` | unrelated | "5,000 comments" page cap | | `.claude/skills/pm-dispatch/references/platform-readings.md`, `references/rest-channel.md` | unrelated | rate-limit quotas (5000/h) | | `board-snapshot.mjs`, `ci-failure.mjs`, `close-cards.mjs`, `fleet-token.mjs`, `issue-create.mjs`, `label-write.mjs`, `sweep-stale-finding.mjs`, `write-pace.mjs`, `check-dispatch-gates.mjs` | unrelated | numeric coincidences (fixture ids, timeouts, quota numbers) | ### New pins `check-governed-merges.mjs` — battery "⭐ the SIZE predicate": an authorized approval lifts (exceeds stays true, `landsByHumanMerge` false); threshold / strict comparison / inclusion unchanged; the words print approver, commit, `objectstack#20153` and the seat landing; exit is the NOT-governed code; no approval / `unapproved` / unauthorized / `unreadable` / the generic reduction (no `approvals` field) each still fire; an approval under the line lifts nothing; a governed PATH and a FORK head are untouched by the lift; the sweep still lists an oversized landing; the not-lifted words name both landings. `check-governed-queue-guard.mjs` — battery "⛔ objectstack-ai#19036: the SIZE line at the queue": the governed leg's verdict object is REUSED (zero extra reads, group exits 0); objectstack-ai#20125 replay — approval on the head and on an older commit both exit 0 with two reads; the block prints approver, commit, card and ruling; no / unauthorized / dismissed / superseded → 8 with the reason named; unreadable review list → 8 never 9; no review reader → 8; reviews are read only over the line (within PR: none; unreadable size: still 9, none); end to end governed clear + size lifted → 0, and with no approval → 8; `sizeReading` has four states; the authorized verdict carries `approvals` and the generic one does not; the guard's source spells no lift condition of its own; the remedy names both landings and keeps the bypass-rules option (objectstack-ai#19344 battery unchanged and green). ### Verification Both self-tests green at head `52398a3b`: `check-governed-merges --self-test` 454 assertions (was 441), `check-governed-queue-guard --self-test` 292 cases (was 279). Reverse verification (ablation, committed state, `scripts/ablation-replace.mjs`, anchor hit 1 → 0, blob `4c5598c0d0c5` → `c21c9c1a3338`, restored to the HEAD blob with `git diff HEAD` empty, both legs): mutating the sibling's `sizeLimbFires` to ignore the lift reddens the guard's self-test (6 of 292 cases fail: reuse, objectstack-ai#20125 replay, block words, end to end, four states) and the sibling's own (3 failures). Direction: 转红, as predicted. Gate list from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `52398a3b` — 40 commands, all exit 0, reconciled with `--ran` (40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN; every line carries its exit code): | command | exit | |---|---| | `node scripts/check-ci-filter-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 | | `node scripts/check-declaration-mirrors.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-scripts-symbol-anchors.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-self-test-wired.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-self-test-workflow-commands.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-skills-token-ratchet.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/check-whole-set-label-write.mjs` (+ `--self-test`) | 0 / 0 | | `node scripts/pm/bare-root-worklist.mjs --self-test` | 0 | | `node scripts/pm/check-governed-queue-guard.mjs --self-test` | 0 | | `pnpm check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:closing-target-claim`, `check:cross-package-test-inputs`, `check:declared-population-live`, `check:docs-audit-scope`, `check:driver-memory-census`, `check:entry-guard`, `check:gitlink-declared`, `check:nul-bytes`, `check:parse-guard` | 0 each | | `pnpm check:pm-dispatch-gates` (894 s) | 0 | | `pnpm check:pm-governed-merges`, `check:pm-governed-prose`, `check:pm-skill-id-lint`, `check:pm-skill-ratchet`, `check:pnpm-filter-targets`, `check:ratchet-remedy-authority`, `check:refd-timer-probe`, `check:required-contexts`, `check:watch-hint-literal` | 0 each | Line budget: `AGENTS.md` 1105 → 1106 (ceiling 1116, headroom 10); `SKILL.md` and `landing-operations.md` unchanged (headroom 0 on both, untouched). `check-skill-line-ratchet` green. Changeset: none — the diff touches `scripts/pm/**` and `AGENTS.md` only, nothing any released package ships; `skip-changeset`. ## Acceptance notes - `check-governed-merges.mjs --pr 20125` could not be read from this container (page 2 of the files walk answered HTTP 403 through the env-token channel; the tool refused rather than answering on a subset). Not a defect; the container's credential asymmetry. - The sweep's `sizeCell` does not annotate an oversized landing with the approval that lifted it (the card allows, does not require, that note); the sweep reads `merged_by`, not reviews, so adding it would add a review read per oversized row. Left as is. - `check-skill-line-ratchet.mjs` carries a ceiling-ledger comment narrating the 2026-09-18 ruling as "takes the same terminal"; it is history of a count, not a rule statement, and is untouched. ## 维护者速读(草稿) **改了什么**:队列守卫的「超 5000 行」这一腿,现在和受管路径(Tier H)一样,承认你(或 `GOVERNED_APPROVERS` 里的账号)的 APPROVED 审查:批准过就放行,由认领席走队列落地;你直接合并这条路不变。阈值 5000、严格大于、含生成物,一个都没动;fork PR 不受此影响;事后审计照样把超 5000 行的落地列出来。 **为什么改**:你 2026-09-27 的裁决「所以阈值写死成 5000 行 , 维护者已经批准了就是可以合并。」——objectstack-ai#20125 已获 os-zhuang 批准仍被队列两次踢出,就是这条旧规则(「只认人工合并」)造成的。 **风险与代价(含回滚)**:代价是队列对每个超线的 PR 多读一次 review 列表(已被治理腿读过的直接复用,不重复读);批准后再推的提交不再被这一腿复审——与 Tier H 路径已接受的成本同形。review 列表读不到时按「未解除」拒收(退出码 8),不会误放。回滚 = revert 本 PR,两份脚本的自测各自变红,不会静默。 **席位意见**:(留空,由席位定稿) **你要做的**:一个动作——本 PR 触及 `AGENTS.md`(Tier H),请 APPROVE 本 PR(或直接合并);批准后认领席走队列落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_0148fenvVvyQV9HYxgVDQ33q)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20102
Clause-②: yes
What this does
Retires the saved-report stack whole, per the maintainer ruling on the card (「A. 退役」, then 「你直接派发处理这个退役任务。」): the
reportsplatform capability, the saved-report service contract in@objectstack/spec/contracts, thesys_saved_report/sys_report_scheduleplatform objects, the eight/api/v1/reportsroutes, the SDK'sclient.reportsnamespace and the@objectstack/plugin-reportspackage. No deprecation window.Not touched: the
reportmetadata kind (ReportSchema,defineReport,/meta/report, datasets, analytics). The two only shared a word.requires: ['reports']is refused loudly bydefineStack(STACK_CAPABILITY_UNKNOWN, 422) with a prescription, not the typo advice: "requires: 'reports' was removed in @objectstack/spec 17.5.0 — the capability mounted the saved-report stack (…), which had no consumer and was retired whole. Delete the token. A report isreportmetadata (ReportSchema…); a saved ad-hoc object query is a ListView on that object." The text lives in the newRETIRED_PLATFORM_CAPABILITY_GUIDANCE(@objectstack/spec/kernel), which is kept disjoint fromPLATFORM_CAPABILITY_TOKENS. The token meets the same text at all three doors that readrequires:defineStackrefuses it (above);os servewarns with it when it loads an older artifact that still declares the token; andos validate/os build, which parse a plain-object config (export default { … }, nodefineStackcall, so no parse-time vocabulary check), report it as a non-fatal capability advisory whose message is that prescription, never "check for a typo". The last door reads the map inrenderCapabilityMessage(packages/cli/src/utils/capability-preflight.ts) and keeps its existing warn-first posture for unknown tokens.packages/spec/src/migrations/entries/semantic/18.saved-report-stack-retired.ts(surface, replacement, reason, acceptance criteria). The registry region was regenerated. The changeset.changeset/20102-retire-saved-report-stack.mdis**BREAKING**with a FROM → TO line for each surface, markedregistered saved-report-stack-retired(check-adr-0087-registration:[BREAKING+bang+clause-②-narrowing])./api/v1/reportspath gives the platform's standard answer for an unmatched route:404, byte-identical to a path that was never mounted. It is not a405, because no verb survives at those paths. Pinned bypackages/rest/src/reports-routes-retired.test.ts, which uses the realHonoHttpServerwith itsnotFoundseam.Per-site disposition (every site the card lists)
packages/plugins/plugin-reports/**pnpm-lock.yamlregenerated withpnpm install. Removed from.changeset/config.json(fixed group),scripts/check-test-source-alias.mjs(alias ledger row),scripts/engine-double-contract.baseline.json(4 rows),scripts/doc-authoring-prose-id.baseline.jsonandscripts/test-shard-timings.jsonplatform-objects/src/audit/sys-saved-report.object.ts,sys-report-schedule.object.tsaudit/index.tsexports andi18n-extract.config.tsrows. The four*.objects.generated.tsbundles were regenerated bynode scripts/check-i18n-bundles.mjs --write(not edited by hand). The object-list pinbundle-ownership.test.tsandPLATFORM_OBJECTS_BY_PACKAGEin the spec were updated.platform-iana-timezone-columns.test.tsnow pins the survivingsys_job.timezonecolumn on its own.scripts/platform-object-tenancy-census.jsonwas regenerated (84 → 82 registered)packages/rest/src/rest-server.ts— the/api/v1/reportsfamilyregisterReportsEndpointsdeleted (340 lines) along with its provider field. The constructor slot is kept as a retired placeholder typedundefined(_retiredReportsServiceProvider?: undefined). Every later parameter is positional (the #15256 hazard), so deleting the slot would silently re-bind each argument after it at all 276new RestServer(sites. With the placeholder, passing a provider there is a compile error. That error found four test callers that still passed one (execctx-consumer-census,ui-view-environment-ownership,ui-view-route-identity.measurement,ui-view-route-tenancy.measurement); each now passesundefinedpackages/rest/src/rest-route-ledger.tsrest-write-response-internal-fields.tripwire.test.tslost its 5 matching rows. The two/reportsenumeration-oracle tests were deleted with the routes they tested.rest.test.tslost itsreports endpointsblockpackages/client/src/index.ts—reportsnamespaceclient.test.tsnow pins the absence twice: a@ts-expect-erroronclient.reports, whichtsconfig.test.jsoncompiles, and a runtime'reports' in client === false.return-type-precision.test.tslost its two report rowspackages/spec/src/contracts/report-service.tsIReportService,SavedReport,ReportSchedule,ReportQuery,ReportFormat,ReportRunResult,SaveReportInputandScheduleReportInputleaveapi-surface/andexport-origins/, both regenerated withgen:api-surface/gen:export-originspackages/spec/src/kernel/platform-capabilities.ts—reportsPLATFORM_CAPABILITY_TOKENSand its row fromPLATFORM_CAPABILITY_PROVIDERS.RETIRED_PLATFORM_CAPABILITY_GUIDANCEis added;stack.zod.ts#validateKnownCapabilitiesreads itpackages/spec/src/migrations/gen:migration-registryrewroteregistry.tspackages/cli/src/commands/serve.ts,packages/cli/package.jsonreportsrow is removed fromCAPABILITY_PROVIDERSand'reports'fromNEEDS_JOB_OR_QUEUE; the dependency is dropped; the unknown-token warn now uses the retirement prescription.serve-capability-identity.test.tslost its rowpackages/cli/src/utils/capability-preflight.ts(theos validate/os builddoor)renderCapabilityMessage'sunknownbranch returnsRETIRED_PLATFORM_CAPABILITY_GUIDANCE[token]for a retired token (own-property read) and the typo hint otherwise. Pinned by a unit case incapability-preflight.test.ts(queue tier) and byrequires-retired-capability.e2e.test.ts, which runsos validate --jsonandos build --jsonover a plain-object config declaringreportsand a misspelled control token (nightly tier by its.e2ename,integrationproject)skills/objectstack-platform/SKILL.md(Tier H)requires:table that mirrorsCAPABILITY_PROVIDERSno longer listsreports. Its lead-in said "all 20 of its entries"; the map now has 20 entries and the table lists the 19 opt-in ones (package-registryis on the always-on slate and was already absent from the table), so the line now reads "its 19 opt-in entries"packages/core/src/fallbacks/index.ts,packages/lint/src/validate-sortable-fields.tscontent/docs/api/client-sdk.mdx(namespace row),plugins/packages.mdx(the section, and the plugin count 18 → 17),permissions/system-context.mdx(rows 44, 45 and 66 removed, the table renumbered, and the counts regenerated withpnpm gen:system-context-census),protocol/objectql/query-syntax.mdx,ui/apps.mdx(the nav item's text said "saved report"; it points atreportmetadata),README.md(package row).content/docs/releases/is not editeddocs/qa/platform-checklist/areas/dashboards.jsondashboards.saved-report-ownershipanddashboards.report-schedule-dispatch-delivery, are retired, not deleted, as the checklist README's lifecycle requires:status: "retired",retiredReason, revision bumped and ahistoryrow added. Their symbol-anchored sources now point at the retirement (the pin, the D3 entry,RETIRED_PLATFORM_CAPABILITY_GUIDANCE,validateKnownCapabilities), which keeps the file's symbol-anchor floor (22) intact without lowering it. Both ids were unmapped fromcoverage.json→reportFound beyond the card's list (with the reason)
REPORTS_LIST_FAILED,REPORT_DELETE_FAILED,REPORT_GET_FAILED,REPORT_NOT_FOUND,REPORT_RUN_FAILED,REPORT_SAVE_FAILED,REPORT_SCHEDULE_FAILED,SCHEDULES_LIST_FAILED,SCHEDULE_DELETE_FAILED) came out with their only emitter, under the ledger's own "Retiring a code" rule. There is no producer left inpackages/**, and objectui and cloud have no reader (grep over bothorigin/main).content/docs/references/api/{contract,error-code-ledger}.mdxwere regenerated withgen:docs.@objectstack/metadata-protocol: theINVALID_SORThint for{ field, direction }named the retired contract as the source ofdirection. It now names the better-auth adapter'ssortBy, which still uses that word. Code and status are unchanged. Its source comment and the matching comments inspec/data/query.zod.ts,objectqlanddriver-memorytests andeslint.config.mjswere updated in the same way.objectql/engine.ts,objectql/filter-comparand-shape.ts,metadata-protocol/protocol.tsandcore/security/operation-private-keys.ts(plus its pin, which asserted that the scan reaches the deleted file).spec/system/job.zod.ts,spec/data/object.zod.ts,platform-objectssys-job/sys-business-unit.content/docs/capabilities/analytics.mdxandcapabilities/index.mdxadvertised "scheduled email digests" that deliver a report. The saved-report stack was the only thing that delivered one, so the claim is removed (Prime Directive chore: version packages #10).scripts/check-route-envelope.mjs: rest-serverstringError44 → 43 andsiblingCode69 → 58, banked as the ratchet requires.execctx-consumer-census.test.ts: 77 → 69 sites and 100 → 92 mentions. All eight were bare sites; 24 caught / 45 bare, and the caught half is asserted unchanged.tenant-audit-census: 227 → 218 write sites, regenerated withnode scripts/tenant-audit-census.mjs --write, plus six hand-written prose figures on the page.check-system-context-census: thewhy:row reference 61 → 59 and its self-test expectations.check:lockstep-package-count --fix: 70 → 69.packages/spec/llms.txt: 69 → 68 packages.Physical tables — what an existing deployment's
sys_saved_report/sys_report_scheduletables doThey are left in place, untouched. There is no backfill, no reaper and no drop. That follows the repo's measured convention for a retired platform object, cited rather than invented:
packages/cli/src/utils/unmanaged-tables.ts(header): "Retiring an object therefore strands its table forever", and "⛔ It never drops anything, and never proposes a drop … Dropping an existing physical table is destructive and hard to reverse: that decision stays with a human".os migrate planlists such a table in its informational unmanaged-tables section.content/docs/deployment/cli.mdx:os migrate"never drops a table that is absent from your metadata".sys_object:packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts("Existingsys_scim_providertables in deployed databases are left in place untouched … no backfill, no reaper, no migrate command"), whose measured stranded table is the fixture ofunmanaged-tables.test.ts.The new D3 entry's
acceptanceCriteriaand the changeset state the same thing.Evidence surface (re-verified, not taken from the card)
Patterns:
plugin-reports,sys_saved_report,sys_report_schedule,IReportService,/api/v1/reports,client.reports,.reports.(list|save|get|delete|run|schedule|listSchedules|unschedule), the seven contract type names, the nine error codes,ReportsServicePlugin,service.reports,requireswithreports.objectstack at base
d624002: no caller outside the stack's own files and tests. The onlyrequireshits inexamples/**do not includereports.objectui
origin/main2a943bf, and the pinned.objectui-shaf8a9d0f: one hit,content/docs/core/report-schema.mdx:219. It is a localinterface ReportSchedulein objectui's own report-metadata doc, not an import of the spec contract, and it has no runtime reader. The pinned checkout imports nothing this PR removes, so the Console Pin Gate is unaffected.cloud
origin/main48d7066: two test-only readers, recorded here for the PM:packages/objectos-runtime/src/capability-coverage.test.ts:45has thereportsrow the card already names.packages/objectos-runtime/src/capability-loader.test.ts:475-497drivesrequires: ['reports']and assertspkg: '@objectstack/plugin-reports', read from the spec's provider map (line 492).Both go red when cloud installs a spec release without the token. Neither is a runtime consumer, and no app in cloud declares the token.
Remaining hits of the acceptance grep on the PR head, with reasons
git grep -n "plugin-reports\|sys_saved_report\|sys_report_schedule\|IReportService\|/api/v1/reports"still hits the following; every hit is one of these kinds:CHANGELOG.md,content/docs/releases/**(⛔ not editable in a code PR), and past.changeset/*.mdentries that describe earlier shipped changes.17.engine-find-formula-*,17.sharing-execution-context-retired,17.sort-node-direction-rejected,18.engine-dotted-filter-refused,18.filter-text-operator-declared-type-refused,18.platform-timezone-columns-iana-domain-refused), their generated copies inmigrations/registry.ts,spec-changes.jsonanddocs/protocol-upgrade-guide.md, and this PR's own18.saved-report-stack-retired.skills/objectstack-platform/SKILL.md: the capability table'sreportsrow entry is removed (see the per-site table), so that file is no longer a hit. The second isdocs/adr/0096-execution-surface-identity-admission.md:194. Its#isSystem-anchored citation of the deletedpackages/plugins/plugin-reports/src/report-service.tsfailedcheck-adr-symbol-anchors(CI round 1, Lint step 🔗 Broken links detected in documentation #104). Following ADR-0015's precedent, the edit keeps the path as a historical, deliberately unlinked citation and names retire the saved-report stack —sys_saved_report/sys_report_schedule,/api/v1/reports,client.reports,IReportService, thereportscapability and@objectstack/plugin-reports(zero consumers; not thereportmetadata kind) #20102; noanchor-exemptmarker is used. The line cannot land separately, because deleting the file turns that gate red.check-governed-merges --branch HEADate4ead74reads GOVERNED (2 of 114 paths:docs/adr/**×1,skills/**×1), landing tier H (人合), on top of the size-driven human merge. Not edited here: the other historical recordsdocs/adr/**(0021, 0029, 0053, 0073, Tier H), and.claude/skills/pm-dispatch/SKILL.md:164,.claude/skills/pm-dispatch/references/lanes/services.md:8and.claude/workflows/docs-accuracy-audit.js:220, which list the package in lane rosters (Tier S). They stay untouched and are listed below as follow-ups.docs/audits/2026-09-test-log-volume-census.md:309anddocs/qa/platform-checklist/FOLLOW-UPS.md:22(a row marked as fixed).reports-routes-retired.test.ts,reports-capability-retirement.test.ts,client.test.ts), the changeset, the two retired checklist items (their historical steps are kept, as the checklist lifecycle requires), and comments that mark a removal (platform-objects/src/audit/index.ts:16,bundle-ownership.test.ts:36).plugin-reports/service-messagingmembers did" in three connectors,plugin-approvalsandservice-knowledge;plugin-audit/comment-access-hooks.ts:214;service-storage/attachment-access-hooks.ts:118; the timezone-precedent notes insys-job,sys-organization,sys-business-unitandorg-hierarchy-timezone.test.ts; thesharing-service.test.ts:529history docblock;scripts/isystem-census.mjs:20;scripts/optional-error-sink-contract.baseline.json:22; andscripts/check-adr-0087-registration.mjs:599.packages/spec/src/system/environment-artifact.test.tsuses a fictional third-party name,@acme/plugin-reports.scripts/check-doc-route-spelling.mjs:748-901has self-test fixture literals that bring their own ledger.Verification
All readings below are from this branch. The derived gate union was run at head
ecb3bb5(git rev-parse --short HEAD), after the last commit.Builds:
turbo run build --filter=@objectstack/cli... --filter=@objectstack/client... --filter=@objectstack/lint...gave 56/56 tasks; the connectors, plugins, apps andclient-reactclosure gave 57/57 (both underos-verify-lock,VERDICT command-exit 0).pnpm --filter @objectstack/spec buildalso passed.Tests:
@objectstack/spec(full suite)@objectstack/rest(full suite)@objectstack/platform-objects@objectstack/client@objectstack/cli --project unitAlso run:
coreoperation-private-keys.pin(5 passed) andmetadata-protocolprotocol.orderby-vocabulary(11 passed).Typecheck:
spec,platform-objects,rest,client,cli,core,metadata-protocol,objectql,lintanddriver-memoryall exit 0. The firstrestrun was red at exactly the four test callers that still passed a provider into the retired slot (TS2345/TS2322, "not assignable to parameter of type 'undefined'"), which is the placeholder doing its job; after the fix it is green.clienttypecheck green means the@ts-expect-erroronclient.reportsis live, not a TS2578.Ablation, one-off and not kept:
node scripts/ablation-replace.mjsreplaced the retirement branch instack.zod.ts#validateKnownCapabilitieswithif (false as boolean). The anchor went 1 → 0 on disk (blob3c09282f→8bb2143f), andreports-capability-retirement.test.tswent red, 2 failed / 5 passed (the envelope-with-prescription case and the dedup case). The tool restored the file: blob equal to HEAD, andgit diff HEADempty.Gates:
node scripts/pm/dispatch-gates.mjs --commandsderived 164 commands from the merge base;--ranreconciles 164 derived / 164 run / 0 unrun. Results:check:generated(api-surface,export-originsanddocsregenerated),check:adr-0087-registration --base d624002,check:platform-checklist,check:system-context-census,check:tenant-audit-census,check:i18n,check:i18n-coverage,check:route-envelope,check:skill-examples,check:dual-build-cjs-loads,check:api-surface,check:authorable-surface,check:liveness,check:variant-docs,check:nul-bytesandcheck:engine-double-contract.check-engine-split-ratio --days 90andcheck-plugin-teardown-shape --self-test. Each names its own deepen remedy.check:pm-dispatch-gates, at the 480 s and 580 s budgets under box contention, with no failed assertion in its output.check:query-options-erasureread a scratch.examples-buildfile thatcheck:skill-exampleswas deleting at the same moment. Re-run alone, it exits 0.Contract review round 1 fixes (head
e4ead74)All readings are from
e4ead74(git rev-parse --short HEAD), after the last commit, on top of a merge oforigin/mainf09d412.4ee2885): over a plain-object config withrequires: ['reports'], bothos validate --jsonandos build --jsonexited 0 with the warning record{"token":"reports","message":"requires: \"reports\" is not a known platform capability — check for a typo."}. After the fix, both carry{"token":"reports","message":"requires: 'reports' was removed in @objectstack/spec 17.5.0 — … Delete the token. …"}, still exit 0, and the text face prints the same line.capability-preflight.test.ts+vitest-tiers-partition.test.ts: 2 files, 39 passed.requires-retired-capability.e2e.test.ts(run withOS_TEST_TIERS=nightly): 3 passed. The full@objectstack/cli --project unit: 224 files, 3157 passed.specreports-capability-retirement.test.ts+platform-capabilities.test.ts: 39 passed.runtimeexternal-validation-shutdown-clears-timers.test.ts: 6 passed.platform-objectsobject-field-editor-panel-echo-decisions.test.ts: 11 passed. Dogfood, all three shards: 46 files / 354 passed, 46 files / 311 passed, and 45 files / 438 passed (1 file and 3 tests skipped).cli,spec,runtimeandplatform-objectsall exit 0.node scripts/ablation-replace.mjsreplaced the retired-token branch inrenderCapabilityMessagewithif (false) {. The anchor went 1 → 0 on disk (blob355e5a2e→3f19d585). The unit case went red (1 failed / 16 passed), and so did both door cases of the e2e (2 failed / 1 passed). The tool restored the file: blob equal to HEAD, andgit diff HEADempty.dispatch-gates.mjs --commandsover this round's nine paths derived 100 commands, and all 100 exit 0. Nine of them first refused with PREREQUISITE NOT MET (exit 3): thespecdist was stale after the docblock edit, and the CLI and eight other packages had no dist. Each was re-run after the build it named.check-skills-token-ratchetputsskills/objectstack-platform/SKILL.mdat 5831 tokens against a ceiling of 5833; the first spelling of the count line was 5839 and was reworded. The branch-wide--ranreconciliation found 175 derived: the 100 above plus 75 more, and 73 of those 75 exit 0. They includecheck:generated,check:skill-examples,check:spec-changes,check:adr-anchorsandcheck:platform-checklist. The other 2 are NOT MEASURED, and neither is a finding:check-engine-split-ratio --days 90refuses on the shallow clone (exit 2), andcheck:pm-dispatch-gatestimed out at 400 s (exit 124).eslint --no-inline-config --format jsonover the seven changed TS files: 7 files, 0 errors, 0 warnings.--print-configresolves a config for each of the seven. Type-aware linting is not enabled (eslint.config.mjshas noparserOptions.project), so this diff cannot change the verdict on any untouched file.skills/**):skills/objectstack-platform/SKILL.mdhas 487 lines before and after, and 5833 → 5831 tokens. All publishedSKILL.mdfiles together: 4402 → 4402 lines.platform-capabilities.tsnow citesreports-capability-retirement.test.tsfor the disjointness pin. The runtimeExternalValidationPlugindocblock and its test header no longer say "one of only two"Pluginimplementations that ownsetInterval. I counted over non-test sources at4ee2885: 14 files callsetInterval, andExternalValidationPluginis the only enclosing class that implementsPlugin; the others are services, drivers, adapters and helpers. The echo-decisions rationale now namesdataset.fields.measures.formatas the live path and the two saved-reportformatfields as retired.Acceptance notes
RETIRED_PLATFORM_CAPABILITY_GUIDANCE(one frozen const in@objectstack/spec/kernel), so that thedefineStackrefusal, the serve warning and the validate/build advisory share one text. It is the only new export.RestServerretired slot: this is a deliberate placeholder, not a workaround for tolerance. It refuses a provider at compile time, andrest-api-plugin-slot-lookups.test.tsnow lists index 10 as a non-provider and asserts that it is passedundefined.integrationtier beyond the one new spawn file,requires-retired-capability.e2e.test.ts, which ran (the rest is declared to CI); the repo-widepnpm lint(CI-owned);check:engine-split-ratioandcheck-plugin-teardown-shape --self-test, which refuse on this shallow clone and name their own remedy; andcheck:pm-dispatch-gates(a PM-script self-test battery that timed out at the 480–580 s budget under box contention, with no failing assertion in its output;scripts/pm/**is untouched).Follow-ups (not in this PR)
reportsrow inpackages/objectos-runtime/src/capability-coverage.test.ts:45(named by the card), and therequires: ['reports']case atcapability-loader.test.ts:475-497. Both need handling once a spec release without the token is installed there. This is the PM's, per the card.@objectstack/plugin-reportsis a maintainer release act.docs/NORTH-STAR.md:62(Tier H): the row "reports · 报表自己建、自己存、只归自己,还能按时自己发出去" citessaved-report-ownershipandreport-schedule-dispatch-delivery, which this PR retires. Its "send on a schedule" leg no longer has a mechanism. That edit is the maintainer's..claude/**lane rosters (Tier S): the three roster lines in section 3 above still nameplugin-reports.维护者速读(草稿)
sys_saved_report/sys_report_schedule两张系统对象、/api/v1/reports八条接口、SDK 的client.reports、IReportService契约、reports能力令牌和@objectstack/plugin-reports包。对外发布的 platform 技能包里,能力表也同步删掉了reports。报表元数据(report)、数据集和分析服务一概不动。requires里写reports的应用:用defineStack的会在构建时被明确拒绝;纯对象配置的应用跑os validate/os build时,会收到同一段退役提示(只警告,不阻断);os serve加载旧产物时也会警告。三处都提示改用报表元数据或列表视图。os migrate plan会把它们列为无人声明的表,由运维决定是否清理。Generated by Claude Code