Repository navigation
fix(objectql): an in-process engine verb refuses an object name the registry does not resolve (#21516) - #21545
Conversation
…all-through Records every object name the engine's resolver hands to the driver without a registry entry, with its caller frames, into the file named by OS_TEST_UNRESOLVED_CENSUS. Reverted before the refusal lands. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
The census it measured is recorded in the pull request. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
… does not resolve resolveObjectName no longer hands an unresolved name to the driver as a raw table name. It throws the data door's own OBJECT_NOT_FOUND 404, built by one factory in @objectstack/core that the door's object-existence gate now calls too. judgeFilter keeps judging the filter for such a name (it reads nothing). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…e engine's refusal as the not-provisioned case The engine now refuses an unresolved name, so three best-effort platform probes that read a system table by a constant name no longer reach the driver when that object is not registered in a lean/bare composition: ObjectQL.probeInstallOrganizations (sys_organization), SeedLoaderService.resolveSoleOrganizationId (sys_organization) and SysMetadataRepository's history counters (sys_metadata_history). Each now recognises OBJECT_NOT_FOUND attributed to its own object as the same benign "not provisioned here" case it already recognises for a missing table — a path a body cannot reach, never the resolver's old raw-table fall-through. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 149 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 c84a16078041482977cd8ac13a1e738ec366f917 && git checkout c84a16078041482977cd8ac13a1e738ec366f917
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7 && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7
node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d
|
…resolved-name-refusal
… family they write through Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
Deliberate probes of an unregistered name now assert the refusal (the data door's OBJECT_NOT_FOUND envelope, nothing reaching the driver); harnesses where the fall-through was incidental register the objects they write through. The #3770 case-B pin keeps the door's 404 and flips its engine assertion. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…s refusal An unregistered organization object (and a view's unregistered probe object) is no longer read through the driver: the engine refuses the name first, so the declared refusal no longer occurs. The capture stays declared and each pin now asserts nothing was withheld, so a returning read turns it red. The channel-asymmetry pin registers its probe object (unprovisioned) so it still measures a real driver refusal. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
… readers resolve The engine now refuses an object name its registry does not hold, so partial compositions register what a deployment's plugins register: the authz resolver's read set (left unprovisioned, so it still reads "no grants"), the settings service's secret and audit objects, and the approvals fixture's two expected-absent probes (unprovisioned, so its withheld-refusal pin is unchanged). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…l reads The engine now refuses an object name its registry does not hold, so the real-engine import/export and classification harnesses register the family after their DDL; an unprovisioned store still answers the driver's own "no such table", which those pins classify. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…r answer The control's driver lines existed only because the hooks read objects the boot never declared through the engine's raw-table fall-through; the engine now refuses those names before any driver. Each probe read now records its answer, and the control asserts OBJECT_NOT_FOUND and no driver line. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
… engine refuses Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
… refusal; type a mock The record-change org-probe pin asserts the absent organization object is quiet by construction; the conformance stack registers the authz resolver's read set (unprovisioned) that its stubbed auth service never did; the engine.test expand mock types its parameter (test-typecheck ledger). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
… not resolve; changesets An action body through REST /actions reading an out-of-band table by its unregistered name now answers 404 OBJECT_NOT_FOUND for an administrator and a member, with nothing of the table in the answer; the same body on a registered name is served (the control); the data door's own 404 is the reference. Changesets: core minor (new objectNotFoundError export), objectql minor BREAKING narrowing with its ADR-0087 disposition, metadata-protocol patch, spec patch (contract docblock). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…resolved-name-refusal
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37123511365 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…resolved-name-refusal
…ver, not the engine The read-only boot composes no auth plugin, so sys_account is not a registered object there, and the engine now refuses a name its registry does not resolve before any driver is asked. The pre-flight inventories the physical table in its legacy shape, so it reads through the driver the engine routes that name to; the missing-table refusal of that read is still the only one recognised as no rows. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…g driver is the probe's refusal Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
✅ ACCEPT of the merge-queue fix at head
|
…is not offered; the admin read says why (objectstack-ai#21580) Part of objectstack-ai#21476 Clause-②: no This delivers the doors half and the administrator's read half of the triage ruling (`5962758813`). The publish half is left open on purpose: its contract-faithful channel sits behind `packages/metadata-protocol/src/protocol.ts`, which open PRs hold. The call sites are under "Not in this PR". The keyword is `Part of`, so the card stays open after this merges, while that half waits for a decision. ## What On a walled tenancy posture, a public form bound to an object walled by an organization column is no longer offered to anonymous visitors. An anonymous submission carries no organization. On a walled posture the engine refuses an insert without one into such an object (`resolveSystemInsertOrganization`). So the form was served (`GET /forms/contact-us` 200), and then every submit answered `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. This was reproduced on `origin/main` (`6dd99b82c3`) with `bootStack(showcaseStack, { multiTenant: 'posture-only' })`. - **One predicate.** `anonymousFormIntakeUnavailability` in `packages/rest/src/rest-server.ts` returns null when the form can take intake, otherwise why it cannot. It reads two facts and restates neither: - the tenancy service's in-force `posture`. This is the value SecurityPlugin hands the engine (`setTenancyPostureProvider`), so a degraded walled request reads `single` there, and the engine derives the install's organization. - the wall column, from `@objectstack/metadata-core`'s existing `resolveRecordWallOrganizationField`, read over the served object schema (which carries the injected `organization_id`). The object is read only once a wall is in force, so single-posture deployments pay nothing new. - **Both doors read it in one place.** It is called inside `resolveFormBySlug`, the one resolution both `GET /forms/:slug` and `POST /forms/:slug/submit` already call. An unavailable form resolves to `null`, which is exactly the withdrawn form's `404 FORM_NOT_FOUND`, byte for byte. Anonymous callers learn nothing about the tenancy, and there is no second check per door. - **The administrator's read names why.** `GET /meta/view/:name` puts one warning in `item._diagnostics.warnings` per unavailable open form. It sits on both arms (cached and uncached), is located at the form's `sharing` (`config.sharing`, `formViews.KEY.sharing` or `form.sharing`), and names the slug, the object, the wall column, the posture and the remedy (`tenancy: { enabled: false }` when the rows belong to no organization). The reason depends on facts the protocol's validator never hashes, so on the cached arm a `view`'s If-None-Match is compared in REST against an ETag with the reason folded in (the ADR-0106 D3 shape). With no reason, the ETag and the 304 are byte-identical to before (pinned). ### Placement: the predicate lives in `rest`, not `metadata-core` The dispatch assumed `metadata-core`'s `anonymous-form-intake.ts`. I measured that first: any new export there enlarges `@objectstack/metadata-core`'s published index (`export *`), which is a `Clause-②: yes (widening)` change by objectstack-ai#21566's own grading. The dispatch pins `Clause-②: no`. Every reader of the predicate (two doors, one admin read) is in `rest-server.ts`. The predicate composes two rules that are already shared (`postureEnforcesWall` from spec, `resolveRecordWallOrganizationField` from metadata-core), so no rule gains a second spelling. It moves into `metadata-core` on the day a reader outside `rest` exists, for example the publish half's option A below. ## Pins - `packages/rest/src/public-form-intake-availability.test.ts` (16 tests): - The doors are enumerated off the registered routes. The set under `/forms/` must be exactly the two doors, and each door on each walled posture (`isolated`, `group`) is its own row: it answers the withdrawn form's answer byte for byte, and `createData` is never called. - Controls, all accepted: a `tenancy: { enabled: false }` object, the single posture (where the object is not even read), a degraded walled request, and no tenancy service. - Admin read on both arms: the located warning appears on the walled cases, and `_diagnostics` is untouched on the controls. - Validator: the bare protocol ETag revalidates into the reason, the folded ETag gives 304, and the control's ETag is unchanged and still gives 304. - `packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts` (real walled showcase boot): - both doors' raw answers equal the same form's answers once withdrawn env-wide on the same boot, and no `showcase_inquiry` row lands; - the admin read names the reason at `config.sharing`. - `public-form-withdrawal-walled.dogfood.test.ts`: the dogfood control. A tenancy-disabled object on the walled boot still accepts intake, and its admin read now also asserts that no intake warning is present. ## Ablation (one-shot, not kept) Each mutation was applied with `scripts/ablation-replace.mjs` against `packages/rest/src/rest-server.ts`, whose HEAD blob is `239ac2d6` at both `8a8839f9ab` and the final `66ee5294a6`. The direction was predicted before each run. - **A, unit leg: the doors stop reading the predicate.** `return unavailable ? null : { ...match, organizationId };` became `return { ...match, organizationId };` (anchor 1 to 0, blob `239ac2d6` to `43fdf709`). - Predicted: exactly the 4 door rows red, everything else green. - Observed: 4 failed, 12 passed. The GET rows received 200 and the POST rows 201 where 404 was expected; the admin-read rows stayed green because they reach the predicate from their own call site. - Restore: blob equals HEAD and `git diff HEAD` is empty. - **A, dist leg (dogfood).** - The first attempt was a no-op. The same replacement left `unavailable` unused, and the DTS build refused it (`noUnusedLocals`) after the JS bundle had already been emitted, so no test ran. I rebuilt from the restored source, and `ablation-dist-preflight` confirmed the guard present in `dist/index.js` and `dist/index.cjs` with a clean tree. - Re-run with `return unavailable && false ? null : { ...match, organizationId };` (blob `2b2e9c9f`), rebuilt; the preflight found the plant marker in 2 built files. - Predicted: 1 red. Observed: 1 failed (`expected 200 to be 404` on the doors row), 8 passed. - Restore: blob equals HEAD, rebuilt; the preflight found the guard in 2 files, the mutation absent from all 6, and a clean tree; the dogfood run went back to 9/9. - **B: the predicate itself answers "available".** `return tenantField === null ? null : ...` became `return tenantField === null || true ? null : ...` (blob `25ef3c8e`). - Predicted: 7 red (4 door rows, plus the 3 walled admin-read rows: uncached, cached, validator) and 9 green. - Observed: 7 failed, 9 passed. Restore: blob equals HEAD and the tree is clean. The pins asked for an ablation "on one door". There is no per-door read site to ablate: the predicate is read once, in the resolution both doors call. Ablation A removes that one read, and each door's own row goes red. ## Tests (at `66ee5294a6`, after merging `origin/main` `44072fc2b9`) - `@objectstack/rest` full suite (`--project local`): 258 files passed; 4883 tests passed, 326 skipped. `typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK. - `@objectstack/metadata-core`: 17 files / 311 passed; `typecheck` clean (it is unchanged). - `@objectstack/dogfood` `typecheck` clean. Public-form dogfood files (walled intake, walled withdrawal, showcase withdrawal, showcase public form, read-back masking): 5 files / 20 passed. - `@objectstack/runtime` `/meta` parity census, run because it reads `rest-server.ts` source and `rest`'s `dist/`. The four files `meta-list-projection-parity`, `meta-item-read-gate-parity`, `meta-read-org-scope-parity` and `meta-item-envelope` gave 780 passed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 68 commands; 67 exit 0. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (it needs a full workspace build), so it is NOT MEASURED and left to CI. The `--ran` reconciliation accounted for 68 of 68: 67 run, 1 NOT MEASURED, 0 unrun. - eslint, narrowed to the 4 changed `.ts` files (`--no-inline-config --format json`): 4 files, 0 errors, 0 warnings, none ignored. The fifth changed path is a changeset `.md`. The narrowing is sound because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Not in this PR: the publish half The ruling asks the publish surfaces (`PUT /meta/view/:name`, `POST /meta/view/:name/publish`) to say why too. The only located, structured channel those responses carry is `advisories`, and both `SaveMetaItemResponseSchema` and `PublishMetaItemResponseSchema` declare the runtime authoring gate as its producer. The places that would have to change: - `packages/metadata-protocol/src/runtime-authoring-gate.ts`: a gate-local rule would sit beside `findPlatformScheduleOrgGaps`, around line 300. - `packages/metadata-protocol/src/protocol.ts:5612`: the gate is fed `orgWallEnforced: this.orgWallEnforced()`. - `protocol.ts:5938`: `orgWallEnforced()` reads the requested posture (`postureEnforcesWall(resolveTenancyPosture())`), which disagrees with the doors' in-force reading on a degraded deployment. - `protocol.ts:18673` and `protocol.ts:19612`: the attach sites. `protocol.ts` is held by open PRs objectstack-ai#21545 and objectstack-ai#21512, so this PR stops there. The options and a recommendation are in the report on the card. ## Acceptance notes - **Boundary of the predicate.** It reads declarations. The engine also passes a federated (`external`) object, a platform object its inventory has not admitted, and a row a `beforeInsert` hook stamped. A form bound to one of those that also carries a wall column is withheld here although the engine would accept it, which is the fail-closed direction. No shipped hook stamps `organization_id` (`git grep` over the CRM and showcase hooks: exit 1, with a `beforeInsert` control at exit 0). - **New failure mode on walled postures.** An object-metadata read failure now fails both doors closed (GET `500 FORM_RESOLVE_FAILED`; submit through `mapDataError`). Single-posture deployments make no new read. - **Cached arm.** For every `view` read, If-None-Match is now compared in REST rather than in the protocol. Server work is unchanged, because `getMetaItemCached` already delegates to `getMetaItem`. Response bytes, the ETag and the 304 are identical when there is no reason. - **Not stamped by this PR.** The list read (`GET /meta/view`), `/layers`, and the runtime HTTP dispatcher's `/meta` item read. The dispatcher serves no `/forms/*` door, so a dispatcher-only composition has no intake that could be unavailable. - **CRM.** `app-crm`'s lead form (`/forms/contact-us`) is the same class on a walled CRM deployment. Not booted here. - **Console.** Whether the console renders `_diagnostics.warnings`: NOT MEASURED (no `objectui` checkout in this container). --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… driver's own expression, so SQLite runs it (objectstack-ai#21587) Fixes objectstack-ai#21441 Clause-②: yes (widening) ## What changes The ObjectQL face's echoed `sql` and the `POST /api/v1/analytics/sql` body now print a date-bucketed dimension in the bucket expression the driver itself groups by for its dialect. Before, `generateSql` printed `date_trunc('GRANULARITY', col)` on every dialect. SQLite refuses that, and PostgreSQL answers timestamps where the face answers `2026-01`. The route is the one the seat answered for Q1 (A), with Q2 = A from triage: the mechanism governs on every dialect. - **`driver-sql`**: one public member, `SqlDriver.dateBucketSql(objectName, field, granularity)`. It returns `knex.raw(sql, bindings).toQuery()` over the unchanged `buildDateBucketExpr(field, granularity, objectName)`, or `null` where that returns `null`. No change to what `buildDateBucketExpr` returns, and no spec member. - **`service-analytics`**: - `strategies/types.ts`: one optional context member, `dateBucketSql`, beside `sqlDialect`. - `analytics-service.ts`: one optional `AnalyticsServiceConfig.dateBucketSql` and one `baseCtx` pass-through line. - `plugin.ts`: wires the hook from `getDriverForObject`, as `sqlDialect` is wired (structural read, `typeof` guard, `undefined` on every tier that cannot answer). - `objectql-strategy.ts`: `dimExpr` prints the hook's answer, and keeps `date_trunc` where nothing answers. The "REPRESENTATIVE" docstring sentence is narrowed to exactly those cases. The false comment ("the SQL shape the driver's own bucketing implements") is corrected. - **`driver-turso`**: the comment that said `SqlDriver` emits `date_trunc` is corrected (comment only). `REMOTE_FACE_ANSWERS` gains one row, `dateBucketSql: 'inherited'`. Its `satisfies` pin over every key of `SqlDriver` fails the package's build until every public `SqlDriver` member is classified, so the ruled driver member forces this row. The row is not on the package's public surface: it is not exported from the index, and tsup drops it from `dist/`. There is no second bucketing table and no dialect branch in `service-analytics`. ## Where the echo keeps `date_trunc` The hook answers nothing, and the bucket stays representative, in four cases: 1. No hook is wired. 2. The driver has no bucket expression (a non-SQL driver). 3. The granularity is one the driver buckets in memory (`week` on SQLite: `buildDateBucketExpr` returns `null`). 4. The query has a non-UTC `timezone`. Case 4 is the strategy's own gate (`zone && zone !== 'UTC'`). It mirrors objectql's `tzRequiresInMemory` (ADR-0053 Phase 2, D2). A non-UTC zone makes the engine bucket in memory on that zone's calendar, which the driver's UTC expression does not describe. Measured: with `timezone: 'Asia/Shanghai'` the face answers `2026-01: 20, 2026-02: 8`, while the driver's UTC expression would answer `27, 1`. **Not gated: a measure `filter`.** The engine also buckets in memory when a measure carries a `filter` (`hasAggregationFilter`). It does so on the same UTC calendar the driver's expression is held to ("Must match `bucketDateValue()` exactly"), so there the driver expression answers the face's keys, and the echo uses it. Measured on both engines (pinned below). ## Measured **Premise, at `main` `0bddffd55b`.** Measured through the real `createDispatcherPlugin` mount (`POST /api/v1/analytics/query` and `/sql`), default composition, with `SqlDriver` on better-sqlite3 and on a live PostgreSQL 16.14 (private cluster, stopped and removed afterwards). Each query ran 0 raw statements and 1 engine aggregate, so these are ObjectQL-face answers. | cell | the driver ran | echo (= `/sql` body) | the echo, run | |:--|:--|:--|:--| | SQLite month / quarter (date and datetime column) | `strftime('%Y-%m', ...)` / `(strftime('%Y', ...) \|\| '-Q' \|\| ...)` | `date_trunc('month' / 'quarter', col)` | `no such function: date_trunc` | | SQLite week | `select *` (in-memory bucketing) | `date_trunc('week', col)` | `no such function: date_trunc` | | PG month / quarter / week | `to_char((col)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM' / 'YYYY"-Q"Q' / 'IYYY"-W"IW')` | `date_trunc(...)` | runs; keys `2026-01-01T00:00:00.000Z` where the face answers `2026-01` | | any engine, `timezone: 'Asia/Shanghai'` | `select *` (in-memory bucketing) | `date_trunc('month', col)` | SQLite refuses; PG answers UTC buckets | **After, at `a61c6d79a9`** (same harness, same mount): - SQLite month and quarter echo `strftime(...)` on both column types, and run with the face's row count. - PG month, quarter and week echo the driver's `to_char(...)` and run. - SQLite week, and the non-UTC zone on both engines, keep `date_trunc`. - The measure-filter dataset echoes the driver expression. The harness was a scratch copy of `runtime/src/analytics-query-window-validity.test.ts`, deleted after the run. **The MySQL arm is by code read only.** No MySQL server was available. The member renders the driver's own `date_format(convert_tz(??, @@session.time_zone, '+00:00'), ...)` arm through the same `toQuery`. **Turso remote face, measured with a scratch harness (deleted).** A `TursoDriver` in remote mode over a libSQL `file:` client answers `dateBucketSql` byte-identically to the local face, with no connection. libSQL runs it (`2026-01`, `2026-Q1`). Week answers `null` on both faces. Remote mode advertises an empty `queryDateGranularity`, so the engine buckets there in memory, on the UTC keys this expression answers. ## Pins `packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts`, default plugin composition. SQLite runs every time; live PostgreSQL runs behind `OS_TEST_POSTGRES_URL`, which no CI step sets for this package (a named skip there). Run at the final head `5032b8f3e9` with live PG 16.14: **16 passed (16)**, 8 of them live PG. - **Month and quarter** (SQLite), and **month, quarter and week** (PG), on a `date` and a `datetime` column: - the echo equals `generateSql` (the `/sql` body), with no params; - it selects and groups by an expression that is not `date_trunc` and that the driver's own aggregate statement contains; - run through the engine's raw-SQL bridge, it answers the face's rows. - **A measure `filter`** (the engine aggregates in memory): the echo carries the driver expression, and run with its params it answers the face's rows. - **FALLBACK, week on SQLite**: the driver grouped nothing, and the echo keeps `date_trunc('week', closed_on)`. - **FALLBACK, non-UTC `timezone`** (both engines): the face answers the zone's calendar, and the echo keeps `date_trunc('month', closed_at)`. - **FALLBACK, no hook**: an `ObjectQLStrategy` whose context names no hook echoes `date_trunc('month', closed_on)`. The dispatch's pin "on SQLite, week bucketed echoes run" does not hold under the ruled fallback. The SQLite driver buckets week in memory (`dateGranularityCapabilities.week` is false), so the hook answers nothing there and the echo keeps `date_trunc`, which SQLite refuses. It is pinned as a fallback instead. ## Ablations Each was predicted first, run through `scripts/ablation-replace.mjs` in wrap mode, and its restore was proven by the tool: blob equals the HEAD blob, and `git diff HEAD` is empty. The subject is `src`, imported relatively by the pin, so no `dist` leg applies. - **A. Hook wiring removed** (the plugin's `dateBucketSql,` config line replaced by a comment). - Predicted: red, 12 failed / 4 passed. - Observed: red, **12 failed / 4 passed**, every failure `expected 'date_trunc(...)' not to contain 'date_trunc'`. The 4 fallback pins stayed green. - Restore: blob `e1378f313e49` equals HEAD's. - **B. The non-UTC gate removed.** - Predicted: red, 2 failed / 14 passed. - Observed: red, **2 failed / 14 passed**: the two non-UTC pins, which got `strftime(...)` and `to_char(...)` where they expect `date_trunc`. - Restore: blob `3769d2062c91` equals HEAD's. ## Verification At final head `5032b8f3e9` unless noted: - `pnpm --filter @objectstack/service-analytics test` (no PG, as CI runs it): 176 files, **4160 passed**, 261 skipped, exit 0 (at `1e3cc1fc72`; the only later change is the changeset file). - The same suite with live PG 16.14 set: 4418 passed, **1 failed**. The failure is `read-scope-temporal-coercion.test.ts`'s premise check, "the server is not on UTC": this private server ran `Etc/UTC`. That is environmental, not this change. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc --listFiles` reaches the new test file. - `pnpm --filter @objectstack/driver-sql test`: 216 files, **3611 passed**, 204 skipped, exit 0. `driver-sql typecheck`: exit 0. - `pnpm --filter @objectstack/driver-turso test`: 88 files, **2366 passed**, 33 skipped, exit 0. `driver-turso typecheck`: exit 0. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 67 commands. All 67 ran at `5032b8f3e9` and exited 0. `--ran` reconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (no `dist`) and was re-run after a root build. - Lint, a declared narrowing: 1. **Population**, read from eslint's own config: of the 8 changed paths, the 7 `.ts` files are linted, and the changeset `.md` is ignored ("no matching configuration"). 2. **Count**, from `--format json`: 7 files linted, 0 errors, 0 warnings, at `5032b8f3e9`. 3. **Invariance**: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move an untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **Comment drift left in place (excluded):** the `objectql` `engine.ts` comment above `tzRequiresInMemory` still calls native driver bucketing `date_trunc`. Open PR objectstack-ai#21545 holds that file. Carrier: whoever next edits that comment. - **Comment drift in a governed surface (noted, not filed):** `skills/objectstack-ui/rules/dashboards.md` ("Engine support") says Postgres buckets with `date_trunc`. `SqlDriver` emits `to_char(... AT TIME ZONE 'UTC', ...)`. `skills/**` is Tier H, so it is not touched here. Carrier: none. - **SQLite week stays unrunnable:** `date_trunc('week', ...)`, by the ruled fallback. Closing it needs the driver to bucket week in SQL on SQLite (a `driver-sql` capability decision), which this card does not make. - objectstack-ai#21485 (the PG and MySQL `date`-column shift under a non-UTC server) is not addressed here. Because the echo now renders the driver's expression, its fix reaches the echo with no second edit. - Merged `origin/main` once (`cc645f2385`, docs-only, disjoint). A later `main` commit (`a7ab047cf6`, `packages/rest` only) is not merged; the queue rebuilds on it. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…er's tenant marker, so an unscoped kernel answers an expanded view as env_local does (objectstack-ai#21511) (objectstack-ai#21603) Fixes objectstack-ai#21511 Clause-②: no ## What this changes On an unscoped (control-plane) kernel, registry hydration registers every view a stored environment-wide view container expands, each under its own name. `hydrateOverlayIntoRegistry` registers the container with the tenant-authorship marker (`stateTenantAuthorship`, ADR-0010 `_provenance: 'org'`), and `hydrateExpandedViewItems` registered the expansions without it. An expansion of a package-bound container therefore sat under its bare name, wearing that package's `_packageId` and no marker, and `SchemaRegistry.getArtifactItem`'s bare-key fallback took it for a view the package ships. This PR implements triage's ruling (comment 5964348889): each expansion inherits its container's authorship. - `hydrateExpandedViewItems` asks `expandRuntimeViewContainer` for tenant-authored expansions (`tenantAuthored: true`). - Under that option, `expandRuntimeViewContainer` applies `stateTenantAuthorship` to each expansion BEFORE that expansion's own artifact envelope is merged over it. This is the order the container gets (`mergeArtifactProtection(stateTenantAuthorship(data), envelope)`), so where the container's own package ships a view of that name, the artifact's `_provenance`, `_packageId` and `_lock` still win (ADR-0010 §3.3). - The two registry-free reads that call `expandRuntimeViewContainer` (the list read's expansion pass and the by-name read's step 1b, through `expandStoredViewContainers`) pass no such option and serve exactly what they served before. - No reader changed. `isArtifactBacked` (which `resettable` reads) and the layered read's `code` arm already ask `isTenantAuthored`; they now get the marker to read. ## Measured before the change At base `a7ab047c`, with the objectstack-ai#21508 harness (`showcaseHarness`) and the card's probe (a container `os_qa_probe` on `showcase_task` with `listViews.in_progress`, read as `showcase_task.os_qa_probe.in_progress`): | kernel | container | `getMetaItem(...).resettable` | `getMetaItemLayered(...).code` | |---|---|---|---| | `env_local` | package-bound (`com.example.repairassets`) | `false` | `null` | | `env_local` | package-less, environment-wide | `false` | `null` | | unscoped | package-bound | **`true`** | **the hydrated expansion** | | unscoped | package-less, environment-wide | `false` | **the hydrated expansion** | At the base the registry held the container as `{ name: 'os_qa_probe', _provenance: 'org' }` and the expansion as `{ name: 'showcase_task.os_qa_probe.in_progress', _packageId: 'com.example.repairassets' }`, with no `_provenance`. For the package-less arm the expansion carried no `_packageId`, so `resettable` was already `false`. Its `code` layer was still wrong, through the layered read's runtime-only `getItem` arm, which drops only tenant-marked entries. After the change, both kernels give `env_local`'s answer for every member kind in both arms. ## The save door (ruling: no save-door rule change) The fix changes what `isArtifactBacked` answers for an expanded name on the unscoped kernel, and the save door reads that predicate. The door's acceptance is pinned rather than assumed. After the container is saved, a write by the expanded name is accepted on both kernels and in both arms, stored once in the container's scope, and that row then answers the name on the by-name read and on the object door. The outcome is identical across the two kernels. The same probe was accepted on all four kernel and arm combinations at the base. Under reverse verification leg 1 below, the save-door pins stay green, so the acceptance does not move with the fix. ## Tests `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts` gets a new describe block, `objectstack-ai#21511 an expanded view of a stored container answers as tenant-authored on both kernels`, with 15 tests in objectstack-ai#21508's harness. It covers the package-bound and package-less environment-wide arms. Hydration never registers an organization-scoped row. - For each arm and each of the five member kinds: the expanded view is not resettable and has no `code` layer on either kernel, and the unscoped kernel's whole answer equals `env_local`'s (`resettable`, `editable`, `deletable`, `lock`, `provenance`, `packageId`, `code`). That is 10 tests. - For each arm: on the unscoped kernel, every registered expansion carries the container's marker, keeps the container's package, and is not a code artifact (`isCodeArtifactBody`). - For each arm: the save-door pin described above. - CONTROL: a package-less overlay of the showcase's own `showcase_task` container. Its expansions, `showcase_task.default` and `showcase_task.in_progress`, stay resettable with the packaged `code` layer on both kernels, and on the unscoped kernel the registered expansion keeps the artifact's envelope (`_provenance: 'package'`, `_packageId: com.example.showcase`) over the marker. ## Reverse verification (both runs recorded) Each run starts from the committed fix, and each leg restores with `git checkout HEAD -- ABSOLUTE_PATH`. Each restore is proven by blob hash equal to the HEAD blob, an empty `git diff HEAD`, and a clean `git status`, all inside a script armed with `trap restore EXIT INT TERM`. The subject is imported from source (`./index.js`), so no `dist/` is involved. **Run 1, at `e8e00609`** (the fix commit, before merging main): - Leg 1: `protocol.ts` was reverted to the base blob `3ac2573f` (`git restore --source=a7ab047c`). The landing was proven by the on-disk blob equalling the base blob; the base blob carries 0 occurrences of `tenantAuthored: true`, and HEAD carries 1. Result: **12 failed, 132 passed (144)**. The 10 `resettable`/`code` pins failed with `unscoped: no package ships it: expected true to be false` (package-bound) and `unscoped: no artifact, so no code layer: expected {…} to be null` (package-less). The 2 marker pins failed with `expected undefined to be 'org'`. The 2 save-door pins and the CONTROL stayed green. - Leg 2: the marker was applied after the envelope instead of before, through `scripts/ablation-replace.mjs`, with anchor 1→0, replacement 0→1, and blob `b106f11e` → `5d6e6263`. Result: **1 failed, 143 passed**. Only the CONTROL failed: `showcase_task.default: the artifact's envelope is merged over the marker, not under it: expected { _provenance: 'org' } … { _provenance: 'package' }`. - Restored HEAD: **144 passed**. **Run 2, at `09033d87`** (after merging `origin/main` `6c5697df`, which includes the landed objectstack-ai#21545 as `eb9ef791`): - Leg 1 reverted to the merged base's blob `24cd0629` (`6c5697df`): **12 failed, 132 passed**, with the same 12 tests and the same messages. - Leg 2: **1 failed, 143 passed**, the CONTROL alone. - HEAD: **144 passed**. ## Verification at `09033d87` - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: Test Files 207 passed, 3 skipped (210); Tests 3207 passed, 19 skipped (3226); `VERDICT command-exit 0`. - `pnpm --filter @objectstack/metadata-protocol typecheck` (`tsc --noEmit`) exited 0. Its `--listFiles` reaches 210 of the package's 210 test files, including the edited test file. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the two edited `.ts` files gives 2 files, 0 errors and 0 warnings. The changeset `.md` is outside every `files` glob of `eslint.config.mjs`. `--print-config` shows no `parserOptions.project` or `projectService` (type-aware linting is not enabled), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 64 families for this change set. 63 exited 0. `pnpm check:dual-build-cjs-loads` is **NOT MEASURED**: it exited 3 (PREREQUISITE NOT MET), because it needs every package's `dist/` and 67 had none. This diff changes no exports, entry points or build config. `--ran` reconciliation: 64 accounted, 63 run, 1 NOT MEASURED. Two first runs were prerequisite misses and were re-run green after the prerequisite was met. `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into the shallow clone. `check:lean-entry-closure` needed `@objectstack/objectql` built. ## Region and surface `protocol.ts` hunks: the `stateTenantAuthorship` docblock (its "ONE caller" sentence now names both callers), `expandRuntimeViewContainer`'s options type and its merge line, and `hydrateExpandedViewItems`' call and docblock. The claim names the `hydrateExpandedViewItems` region. `expandRuntimeViewContainer` sits in the same hydration block, and the stamp must go there so that it precedes each expansion's own envelope (the order above), without a second copy of the envelope rule. That is the one widening of the region, declared here. The save door and the data door's read region are not edited. objectstack-ai#21545's hunks (landed as `eb9ef791`, merged here) are disjoint from these. ## Acceptance notes - **Card premise, refined:** for the package-less environment-wide arm, only the `code` layer was wrong at the base; `resettable` was already `false` (table above). Both values are pinned now. - **Save-door intent on the unscoped kernel:** for a write by an expanded name of a package-bound container, `isArtifactBacked` now answers `false`, as on `env_local`. Reading the save path (not separately measured), the write intent it derives is therefore the runtime-only one rather than the artifact-override one. The ruling expects this ("layered by the corrected predicate on both kernels"). Acceptance is unchanged, as measured above. - **Not measured over REST.** The reads are pinned at the protocol methods the by-name and `/layers` REST routes call. - The branch was merged with `origin/main` at `6c5697df`. One later main commit (`f6b75208`, spec conformance-case notes and a lint test) is not merged. It touches none of this PR's files. Changeset: `.changeset/21511-expansion-tenant-marker.md`, `patch` for `@objectstack/metadata-protocol`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ Co-authored-by: Claude <noreply@anthropic.com>
… an orphaned column (objectstack-ai#21612) Fixes objectstack-ai#21571 Clause-②: no (narrowing) An unprojected read now serves the object's declared fields plus the platform's system columns. A column no metadata declares, such as a field retired in an upgrade whose column additive sync leaves behind, no longer leaves the engine. The decision is made once, in the engine (`packages/objectql`), so every driver and every door gets the same answer. No driver source and no `metadata-protocol` source is edited. ## Measured first (before any fix) The probe was a temporary, env-gated recorder at the engine's read sites (commit 920cecf, reverted by a7500b3; the net diff carries none of it). In trim mode it deleted every undeclared key at `find`, `findOne`, the by-id update result and the update/delete prior reads, a superset of this fix. | suite (tree) | files | under trim | undeclared-key hits | | --- | --- | --- | --- | | objectql (a7ab047) | 366 | 8 failed, 7374 passed | 50 | | rest (a7ab047) | 258 | all pass | 0 | | plugin-auth (a7ab047) | 118 | all pass | 1 (a mock `sys_account` row) | | service-automation, plugin-sharing, plugin-audit (a7ab047) | all | all pass | 0 | | runtime (778bf5c, with objectstack-ai#21545 merged) | 318 | all pass | 0 | The 8 objectql failures, by cause: - 2 in `engine.test.ts` (objectstack-ai#3617): the `sys_migration` double declared only `id`. The production object declares `blocking`, `verified_at` and `last_run_at`, and `readMigrationFlagVerified` reads those. The fixture now declares what production declares. - 5 in `engine.test.ts` / `plugin.integration.test.ts`: red only at the prior-read probe sites (a fixture's `readonlyWhen` reads an undeclared `locked`). This fix does not touch prior reads. - 1 in `driver-fault-boundary-redaction.test.ts`: the mock driver hands back live references to its store, and an in-place delete corrupted it. So the fix never mutates a driver row: a row carrying an undeclared key is copied without it. In-process readers of undeclared columns, by call site: - `os migrate plan`'s `unmapped_column` detection introspects the table through the driver (`driver-sql/src/schema-drift.ts`). Unaffected. - `os migrate account-issuer` reads `sys_account.issuer` through the driver (`cli/src/commands/migrate/account-issuer.ts`). Unaffected. - The `os migrate apply` account preflight (`cli/src/commands/migrate/apply.ts`, then plugin-auth `account-identity-preflight.ts`) calls `engine.find` with an explicit projection naming the retired `issuer`. The engine's unknown-plain filter already drops that name today. Unchanged by this PR (see Acceptance notes). - The metadata column migrations (`packages/metadata/src/migrations/*`) use driver raw DDL. Unaffected. - `cloneData` copies every key of its `findOne` source into an insert. Before this fix, an orphaned column made the clone fail at the write door (`Unknown field 'mailing_street'`, `INVALID_FIELD`); it now succeeds. This reader was hurt by the orphaned column, it did not rely on it. - Export, search, the RPC dispatcher, `getData` and `findData` all read through `find`/`findOne`. They narrow with the door, which is intended. - hotcrm's `scripts/backfill-contact-mailing-address.ts` is external and reads through REST. The changeset names its interim route. Hypothesis verdicts: - **H1 (confirmed):** `find` reached the driver with `fields` undefined, and driver-sql answered with `select('*')`. - **H2 (confirmed, and it decided the shape):** on the composed REST harness, an explicit projection naming a declared field with no column fails, and driver-sql's ladder returns the whole row, retired column included (the reach pin's ladder case, red before the fix). So the engine shapes the rows the driver returns instead of pushing a projection down. That holds whichever rung answered, and needs no driver edit. - **H3:** one authority, the registry field map plus `PLATFORM_PROVISIONED_COLUMNS`. That list moved into `declared-read-columns.ts`. The explicit-projection filter (`find`/`findOne`), the new default projection and the write door's `undeclaredWriteFieldErrors` all read it. No second list. - **H4:** the shaping runs before formulas, `expand`, file references and the hooks. Internal omission, credential masking and the `__search` strip still run after the hooks. The conformance matrix pins formula, password mask, `internal`, and each system column. - **H6:** only `find` and `findOne` return row bodies. `count` returns a number. `aggregate` returns aggregates, and its `groupBy` names are gated at the door. `findStream` was retired in 17.0. `expand` re-enters `this.find`. ## Changes - `packages/objectql/src/declared-read-columns.ts` (new): `PLATFORM_PROVISIONED_COLUMNS`, `declaredColumnSet` (no opinion for an absent, array or empty field map, the rule the read and write doors already share), and the non-mutating `withDeclaredColumnsOnly` / `rowsWithDeclaredColumnsOnly`. - `packages/objectql/src/engine.ts`: `find` and `findOne` shape the driver's rows right after the driver call. The explicit filter and the write door read the shared list. - `packages/objectql/src/no-operator-object-door.ts`: a comment pointer to the list's new home. - Tests: `packages/rest/src/data-query-unprojected-declared-fields.test.ts` (the reach pin), `packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts` (the conformance matrix), and the `sys_migration` fixture in `engine.test.ts`. - `.changeset/21571-unprojected-read-declared-fields.md`: `@objectstack/objectql` minor, BREAKING narrowing, ADR-0087 `not-required (no-migration-prescription)`, and the interim route. - `content/docs/data-modeling/queries.mdx`: one paragraph stating the default projection. ## Pins - **Reach pin.** The harness is the composed REST harness this package already uses: `RestServer`, then `ObjectStackProtocolImplementation`, then `ObjectQL`, then a real `SqlDriver` on better-sqlite3 with a file database. Boot one declares two mailing fields and writes values. Boot two retires them, the card's upgrade. Then `POST /api/v1/data/rq_contact/query` with no `fields`. - Before the fix: 4 failed, 2 passed. The retired columns were present on the query and on `GET /data/:object/:id`, every-key-declared was red, and the ladder rung was red. The system-columns pin and the explicit `INVALID_FIELD` / 400 pin were green. - After the fix: 6 passed. - **Conformance matrix.** `objectql`, three driver behaviours (`whole row`, `projection`, `ladder`) × doors (`find`, bare `find`, `findOne`, ladder projection, retired-only projection, `expand`, `findData`, `getData`, `cloneData`), plus declared-treatment and store-not-mutated pins. A future driver shape is one entry. - **Explicit projection** of a retired column still answers `INVALID_FIELD` / 400. The test asserts `code` and `status`. - **Pin sweep.** Repo-wide `git grep` over tests for orphaned, unmapped, retired or undeclared column readings, plus the trim-mode runs above: no test asserted that an unprojected read returns an undeclared column. The only fallout was the `sys_migration` fixture, which now declares its columns. No refusal assertion was changed. ## Reverse verification (fix committed first, at 9593fbd) Each shaping call was replaced in turn with `node scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed). Then objectql was rebuilt (exit 0), and `ablation-dist-preflight --absent` confirmed the call is absent from all 14 built files. - `find` call removed: reach pin 3 failed, 3 passed (query, every-key-declared, ladder). Matrix 17 failed, 19 passed. - `findOne` call removed: reach pin 1 failed, 5 passed (GET by id). Matrix 9 failed, 27 passed (`findOne`, `getData`, `cloneData` × 3 shapes). The clone's failure reads `Unknown field 'mailing_street' on object 'rq_contact'`. - Restore: `git checkout HEAD -- packages/objectql/src/engine.ts`. Blob aadf401 equals the HEAD blob, `git diff HEAD` is empty and `git status --porcelain` is clean. After a rebuild, the preflight finds both calls present in dist. Reach pin 6/6 and matrix 36/36. ## Local verification, at 4bc22fe (the PR head, after merging main, which includes objectstack-ai#21545) - `pnpm --filter @objectstack/objectql test`: 367 files, 7415 passed. `test:repo`: 1 file, 5 passed. `typecheck` (tsc plus test-typecheck): exit 0. - The reach pin: 6 passed. `pnpm --filter @objectstack/rest run typecheck`: exit 0. - The full rest suite ran at 99033a9: 259 files, 4889 passed, 326 skipped. The last merge (3 commits) touched neither `rest` nor `objectql`, so that run was not repeated. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) re-derived the same 96 commands at this head. All 96 were run and reconciled with `--ran` (96 run, 0 unrun). - 95 exit 0. - `node scripts/check-engine-split-ratio.mjs --days 90` answered exit 2: NOT MEASURED. The clone is shallow inside the 90-day window, and this is a report-only metric. - Lint, narrowed and proven: - Population, from eslint's own config: the 8 changed files went to `eslint --no-inline-config --format json`. eslint reports the `.md`/`.mdx` pair as "File ignored because no matching configuration was supplied". - Count, from the JSON: 8 files in the report, 6 TypeScript files linted, 0 errors on them. The 2 warnings are the two ignore notices. - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict. - Not run locally, declared to CI: the remaining downstream consumers of `@objectstack/objectql`. The trim-mode measurement above covered rest, runtime, plugin-auth, plugin-sharing, plugin-audit and service-automation under a superset of this change. ## Acceptance notes - **Write responses still carry orphaned columns. This is a finding for the seat, outside this card's read ruling.** - Measured after this fix on the same harness: `PATCH /api/v1/data/rq_contact/c1` with `{ name }` answers 200, and its `record` carries `mailing_street: "1 Retired Way"`. - Cause: the engine's by-id update returns driver-sql's readback (`select *`), and `updateData` strips only `internal` fields from it. - The create 201 and clone 201 bodies are built from driver-sql's `returning('*')` too. That is not measured here; on a new row the orphaned columns would read null. - The A-prime ruling keeps engine write results whole for privileged writers, so where to cut this is a decision. It is not taken here. - Prior reads (the update/delete `previous` rows handed to hooks) still carry orphaned columns. In process only, no public door measured. Carrier: none. - In process, `engine.aggregate` does not refuse an undeclared `groupBy` name. The data door does (`assertGroupByFieldsExist`). Carrier: none. - The `os migrate apply` account preflight's `issuers` label reads `(none)` on the engine path, because the engine already drops the retired `issuer` from an explicit projection. The collision verdict keys on provider and account ids and is unaffected. `os migrate account-issuer` reads through the driver and shows real issuers. Carrier: none. - objectstack-ai#21573 (the operator-only `os migrate` read of unmapped columns) is not addressed here. It is the interim route the changeset names. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… sys_organization (objectstack-ai#21628) Fixes objectstack-ai#21597 Clause-②: no ## What changed - **`packages/objectql/src/lifecycle/lifecycle-service.ts`**: `loadGovernance`'s tenant scan now asks `engine.registry.getObject('sys_organization')` before it reads that object. - An **unregistered** `sys_organization` is the single-tenant answer. There is no read and no tenant override, and the sweep runs one global pass on each declared window. - A **registered** one is read exactly as before. The catch still accepts only `isMissingTableError(error, 'sys_organization')`. Every other failure still aborts the sweep, and that includes an `OBJECT_NOT_FOUND` thrown from that read. - **`LifecycleEngineLike['registry']`** declares the optional `getObject?(name: string): unknown` member the scan reads, because a published type must not refuse a key the code below it reads. - It is optional so that a double modelling only `getAllObjects` stays a legal engine. Such a registry cannot be asked, and the scan then reads as before. - Every real engine has it: `ObjectQL.registry` is the `SchemaRegistry`. - **`packages/runtime/src/expected-read-refusal-noise.ts`** (the claim's declared cross-lane path, comment only): the header no longer says the org probe catches only a missing table. It now says that the probe and the lifecycle snapshot both ask the registry first, and it states what the seed-loader accepts. - **New pins** in `packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts`: six cases on a REAL `ObjectQL` engine over a stub driver, so the refusal the guard avoids is the engine's own and not a double's guess. - **`.changeset/21597-lifecycle-registry-first-guard.md`**: `patch` for `@objectstack/objectql` only. The runtime half is measured below. ## Why: the mechanism, measured - Since `eb9ef791bd`, `ObjectQL.resolveObjectName` throws `objectNotFoundError(name)` exactly when `this._registry.getObject(name)` is falsy. Every in-process verb resolves through it. So `engine.registry.getObject` is the refusal's own predicate: the guard asks the very question the refusal asks, never a list of names. - `getAllObjects` was not used for this. It is a full merging walk with a side effect, and it can disagree with `getObject`'s short-name and FQN index. The dangerous direction of that disagreement is "absent" for an object that resolves. - **Premise at base `36ad3210d4`:** the new file was run before the fix and gave 5 passed, 1 failed. - The premise case shows the engine refusing `find('sys_organization')` with `code: 'OBJECT_NOT_FOUND'`, `status: 404` and `object: 'sys_organization'`, with no driver read at all. - The unregistered case fails on the scan having read `sys_organization` (`expected 1 to be +0`). ## Which shape was followed, and why No shared helper exists: there is no registry-presence helper in objectql, core or types. objectstack-ai#21545 left two shapes: - the engine probe's **registry presence before the read** (`probeInstallOrganizations`); - the seed-loader's **catch-side refusal attributed on `object`** (`resolveSoleOrganizationId`). This follows the engine probe's shape, with its catch unchanged. It is spelled with `MigrationRecoveryPlugin`'s capability check (`typeof ... === 'function' && !...`), because `LifecycleService` holds a duck-typed engine rather than the registry itself. It is not the seed-loader's catch-side shape, for three reasons: 1. The card's ruling names the registry-first guard. 2. On a real engine the registry question makes a catch-side `sys_organization` arm unreachable, so adding that arm as well would make a third variant. 3. An `OBJECT_NOT_FOUND` that arrives after the registry said "registered" either names another object or contradicts the registry. It propagates, and this is pinned. There is no new engine API and no `engine.ts` edit. The registry comes from the engine's existing `registry` accessor, which settles H3. ## Pins (real `ObjectQL`, `engine.find` spied with call-through) | case | asserts | |:--|:--| | control: registered, provisioned, one org with a 90d tenant override | scan reads once (reaches the driver); tenant pass at 90d, then the global 30d pass; no error | | premise: unregistered | the engine refuses the read: `OBJECT_NOT_FOUND`, 404, `object: 'sys_organization'`; the driver saw nothing | | **unregistered: single-tenant sweep** | `report.errors` empty; zero scan reads; one global 30d pass; `report.swept` records the 30d cutoff; no warn | | registered but unprovisioned (`no such table`) | the scan reads and the driver throws; global pass; no error (unchanged missing-table answer) | | real driver fault (`ECONNREFUSED`) | the scan's read rejects with `code: 'ECONNREFUSED'`; no candidate read, no delete, nothing swept; `report.errors` and the warn quote that fault's own message | | `OBJECT_NOT_FOUND` attributed to ANOTHER object (a `beforeFind` hook reads an unregistered `sys_org_unit`) | rejection `OBJECT_NOT_FOUND`, 404, `object: 'sys_org_unit'`; the sweep aborts; not read as absence | ## Verification, at final head `190cea2e9e` - **Reverse verification.** The fix was committed first, then the guard was ablated through `scripts/ablation-replace.mjs`: the anchor hit once, and the blob went `a3ff3c2d4d3d` to `6c3fa03e505d`. Result: `src/lifecycle/` gave **1 failed, 115 passed (116)**. Only the unregistered pin went red, and it reported the defect itself: `governance snapshot could not be loaded (Object 'sys_organization' not found) — sweep aborted before any policy was applied`. - The restore was `git checkout HEAD -- ABSPATH`. The blob is back at the HEAD blob `a3ff3c2d4d3d`, `git diff HEAD` is empty and `git status --porcelain` is empty. - The first run of the same ablation, at `dfb2af2144`, gave the same direction. - The 110 pre-existing lifecycle tests stay green with the guard ablated. Their doubles have no `getObject`, so they never reach the guard. - **Tests.** - `pnpm --filter @objectstack/objectql test`: Test Files 368 passed (368), Tests 7421 passed (7421). - `src/lifecycle/`: 2 files, 116 passed. - **Typecheck.** `pnpm --filter @objectstack/objectql typecheck` exit 0, with `check:test-typecheck: OK`. The new file is in the test program (`tsc -p tsconfig.test.json --listFilesOnly`: 1 hit) and compiles with zero errors. - **Import side.** objectql's published `.d.ts` gains the optional member. Three test files in other packages build typed `LifecycleEngineLike` doubles, all `registry: { getAllObjects }` and none passing `getSettings`. - `pnpm --filter @objectstack/service-messaging typecheck` exits 0. - Reverse leg: pasting `getObject: 42` into its double gives `TS2352 ... The types of 'registry.getObject' are incompatible` against the rebuilt `.d.ts`. The same paste passes an `as` assertion against the old type, so this proves the consumer read the rebuilt declarations. Restore proven. - **Runtime.** - `pnpm --filter @objectstack/runtime typecheck` exit 0. - `src/expected-read-refusal-noise.channel-asymmetry.test.ts`: 4 passed. - The edited module is not in runtime's published files. After a build, its header text and its export `captureExpectedReadRefusals` have 0 hits in `dist/`, and the module is 0 of 79 `sources` in both sourcemaps. The positive control `migration-recovery-plugin` is 1 of 79, and its export hits 4 `dist` files. So the changeset carries no runtime entry. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands at `190cea2e9e`, and all 64 exited 0. `--ran` reconciliation: 64 derived, 64 run, 0 NOT-MEASURED (a derived zero, every exit code recorded). - `check:query-options-erasure` was red once on the first head (test surface 236 to 238: two `as any` query options in the new file). Both are typed now, and the ratchet holds at 236. - **Lint (a proven narrowing; `pnpm lint` is CI's).** `eslint --no-inline-config --format json` was run on the three TS files in the diff: 3 files, 0 errors, 0 warnings. The changeset `.md` is outside eslint's configuration ("no matching configuration"). `eslint.config.mjs` states that it never enables type-aware linting, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **Boundary of the ruling, noted, not filed.** Take a composition that registers no `sys_organization` while its database still holds an organization table written by another composition. It now sweeps every tenant on the global window. That is the card's stated semantics, and it is the same answer `probeInstallOrganizations` gives. Since `eb9ef791bd` no in-process verb can read that table by its raw name anyway. - **Clause line.** `Clause-②: no` is copied from the claim. The only type change is the optional member on the published input type `LifecycleEngineLike['registry']`. Every engine accepted before is still accepted, and there is no new export. Precedent: commit `0f38ab084` added the optional `tenancy` key to `LifecycleObjectLike`, and it shipped as an objectql `patch`. - **Branch base.** The branch is 2 commits behind `origin/main` (`f97660cdd6`). Neither commit touches objectql, the lifecycle or the runtime header, so the branch is not merged here. - **Worker count.** The full objectql run passed `--maxWorkers=2` after a bare `--`, which vitest drops, so it ran at vitest's default worker count. It is still a whole-package measurement. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21516
Clause-②: yes (narrowing)
An in-process engine verb now refuses an object name the schema registry does
not resolve with the data door's own
OBJECT_NOT_FOUND(404), instead ofhanding that name to the driver as a raw table name. One name space for the
in-process verbs and the generic data door (triage ruling). The engine's accept
set narrows; no surface is widened. The
yeshalf of the clause line is the onenew export,
objectNotFoundError, in@objectstack/core.What changed
packages/core(newobjectNotFoundError). One factory for theOBJECT_NOT_FOUND/ 404 envelope, besiderecordNotFoundErrorand for thesame ADR-0076 D2 reason (the engine closure cannot import the package where
the door's envelope was written). Both doors now build the refusal here.
packages/metadata-protocol(the door).assertObjectRegisteredraisesthat shared factory: same wire status, same code.
packages/objectql(the engine).resolveObjectNamethrowsobjectNotFoundErrorfor a name the registry does not resolve, rather thanreturning it as a physical table name. Every in-process verb
(
find,findOne,count,aggregate,insert,insertMany,update,delete,validate) resolves through it, so all refuse uniformly: nospelling allow-list, no per-caller marker.
judgeFilterkeeps judging thefilter for an unresolved name (it reads nothing and reaches no driver), as its
contract states.
known system object and were already fail-soft on a missing table now treat
the engine's refusal (attributed to their own object) as the same "not
provisioned in this composition" case. A body cannot reach these paths:
ObjectQL.probeInstallOrganizations(registry-presence guard),SeedLoaderService.resolveSoleOrganizationIdandSysMetadataRepository'shistory counters (refusal recognised by
codeandobject).packages/spec. TheIObjectQLEngine.judgeFilterdocblock states thatexecution refuses an unknown object before admission (comment only; it ships
in the built type declarations).
Why (classes, doors, roles, codes only)
An action body invoked through the actions door could name a protected member
of the stored-metadata family by a spelling the registry does not resolve and
receive its stored content, whether a member or an administrator invoked it.
The in-process verb handed that name to the driver as a raw table name, and
every name-keyed in-process guard (PR #21513's reader seam among them) was
addressed by the registered name only. The generic data door answers
OBJECT_NOT_FOUNDfor the same name. The engine now answers the same, so thetwo doors share one name space and no name-keyed guard can be stepped around by
naming its target some other way.
Census: does any legitimate platform reader rely on the raw-table fall-through?
Instrumented the resolver's fall-through and ran the
objectql,metadata-protocolandruntimesuites, plus a full boot, seed and door driveof four example apps (crm, showcase, todo, multi-package). The instrument was
reverted in the branch; the net diff carries none of it.
Every in-repo caller that passes a possibly-unresolved name, by function:
ObjectQL.probeInstallOrganizationsSeedLoaderService.resolveSoleOrganizationIdSysMetadataRepositoryhistory countersObjectQL.cascadeDeleteRelations/planCascadeAtomicity/referenceExiststry/catchConclusion: no production or example reader relies on the fall-through.
Merge-queue fix
os migrate account-issuerreadssys_accountthrough the driver the engine routes that name to. Its read-only boot registers nosys_account, and the engine now refuses an unregistered name. The refusal is not read as absence, because that would report a table full of accounts as a clean pre-flight. #21570's missing-table reading forsys_accountis kept, and every other failure still throws.Fixture triage (the test-only fallout, per the seat's answer)
Every test that encoded the raw-table fall-through, by disposition. No ADR text
is edited, and no pin ruled under #7929 (the cross-field withholding decision)
changes what it asserts. The table-keyed
captureExpectedReadRefusalsnoisepins keep their subject and reshape their reading (item 3).
refusal (
code+status, and where the test watched the driver, thatthe driver saw nothing).
objectql:engine-20822-no-field-map-type-blind-lowering,query-expression-conformance,engine.test,engine-undeclared-update-field,engine-undeclared-field-preflight,engine-temporal-comparand-door,engine-aggregate-filter/-having/-reference-verdict,engine-summary-recompute-context,registry-field-type-refused-at-door, theglobal-search-*pins,engine-judge-filter,engine-organization-probe-outage.protocol-unregistered-object.test.ts, case B of the card 3770 gate: thedoor's 404 assertion is unchanged; the engine assertion turns from
"serves the row" to "refuses
OBJECT_NOT_FOUND/ 404"; header item ②gains one sentence naming [finding] [security] An in-process engine verb passes an object name the registry does not resolve to the driver as a raw table name, so a sandboxed body reads a protected table by a name the data door refuses #21516.
registry-gate-wiring: the premise reads ground truth at thedriver (host code's declared internal path), then asserts the engine
refuses the same name.
platform reader resolves (registered after boot or DDL, so nothing new is
provisioned and every outage/absence subject keeps its meaning).
objectqlmetadata-write harnesses (delete, save, publish-meta,publish-package-drafts, protocol-derived-provenance,
protocol-save-meta-repo-path, protocol-picklist,
protocol-publish-canonical-fold): the stored-metadata family.
rest(14 harness files): the stored-metadata family, after DDL.plugin-security(4 files) and thehttp-conformancestack: the authzresolver's read set, unprovisioned, so the missing-table answer is still
what they measure.
plugin-approvalsstatus-mirror cascade: the delegation object and theorg object, unprovisioned.
service-settings: the secret andsetting-audit objects.
unregistered org object is now refused before any driver, so a pin that read
"the probe reached the driver and was withheld" now reads "the probe reached
no driver" (
tablesSeen()equal to empty wheresilentChannels()wasread).
runtime(about 17 files) andtrigger-record-change.expected-read-refusal-noise.channel-asymmetry.test.tsregisters its probeobject, unprovisioned, so the real driver refusal is still what its two
channels measure.
cliserved-boot control (schema-migrate.host-composition): eachhook's probe read is witnessed by its recorded
OBJECT_NOT_FOUNDanswer, notby a driver line; the SQL driver suppresses that line for its own deferred
set, so the line never was the subject.
engine.test.ts: one mock parameter typed (name: string), the@objectstack/objectql#typecheckred of the earlier heads.New pin: the measured public door
packages/runtime/src/unresolved-object-name.actions-door.pin.test.tsbootsthe plugin set
bootStackuses and drives REST/actions. The target is atable that exists and holds a sentinel row, created out of band at the driver
and registered nowhere (the class the card measured, naming no protected
table). For an administrator and a member, the action body's read answers
404 OBJECT_NOT_FOUNDand the sentinel appears nowhere in the answer. Control:the same body shape on a registered name is served. Reference: the generic data
door's answer for the same name is the same 404. PR #21513's reader-seam pins
stay green.
Ablation (one-shot; nothing left in the tree)
Mutation leg,
scripts/ablation-replace.mjsonengine.ts: the refusal inresolveObjectNamereplaced by the old raw-table return plus a marker branch(marker on disk 1, refusal on disk 0); rebuilt;
ablation-dist-preflight:marker present in 4 built files. Pins under mutation:
protocol-unregistered-object,engine-20822-...,query-expression-conformance,engine-judge-filter):5 failed | 245 passed (250)2 failed | 4 passed (6)(both role cases)Restore leg:
git checkout HEAD -- engine.ts; blob equals the HEAD blobf5793bff919d,git diff HEADempty, porcelain clean; rebuilt; marker absentfrom all 14 built files. Pins restored:
250 passed (250),6 passed (6).engine.tsis byte-identical on the final head (the later merge ofmaincarried no
objectqlsource).Verification on the final head
6752a29827(merge oforigin/mainat1ca1eb0972)objectqlfull suite:367 files, 7384 passed.metadata-protocolfull suite:206 passed, 3 skipped files; 3187 passed, 19 skipped.runtimefull suite:317 files; 5176 passed, 19 skipped.service-analyticsfull suite:175 files; 4152 passed, 253 skipped.cliunit tier:252 files, 3685 passed; integration tier, the three filesthis branch or the merged
maintouched:36 passed.mcp:35 files, 389 passed; dogfood (registry-gate-wiring+ the twofiles
mainadded):16 passed; themain-added example,metadataandcorefiles: green.plugin-security,plugin-approvals,service-settings,http-conformance,trigger-record-changetest tasks: turbo38/38(5 test tasks run, 33 cached builds).
63/63turbo tasks.bab0903840): typecheck of every touched package76/76tasks;restrepo project177 passed; the 14restharness files552 passed, 21 skipped.6752a29827: every check green except "Part-of PR must not also closeits card", which read the earlier body; this body is its input.
Acceptance notes
internal callers unaffected") is narrowed at the engine: the door's gate is
unchanged and the engine now gives the same answer. ADR-0053's type-blind
lowering is kept for a registered object with no field map, and removed for an
unregistered name (the bypass itself). No ADR text is edited here.
@objectstack/coreminor (Clause-②: yes, the new export);@objectstack/objectqlminor (Clause-②: no (narrowing), with its ADR-0087disposition);
@objectstack/metadata-protocolpatch;@objectstack/specpatch (docblock).
packages/cli/test/refusal-renders-once.e2e.test.ts(added onmain) sitsin neither of the cli package's two vitest projects and was not run here;
it drives refusal rendering of
os init/os compile, which this changedoes not reach.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3