Repository navigation
fix(service-analytics): log the admitObjectRead bridge's init-time security absence at info; WARN once at the first query that finds none - #22234
Conversation
…curity absence at info; WARN once at the first query that finds none The object-level read bridge resolves the "security" service per query, and on a default composition the security plugin registers after analytics' init(). The init-time WARN asserted a verdict the same boot contradicts. Init now logs the situation at info (as the getReadScope sibling does); the bridge's absent branch logs one WARN per plugin instance at the first query that finds no service. What either bridge enforces is unchanged. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ugin-security start()) The comments and the changeset said the security plugin registers "a moment later" on a default composition. Measured: plugin-security registers the "security" service in its start(), after every plugin's init(), so the init-time probe can never see it there. The default-composition pin now registers its security double from start() too. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 80040dc7ce7987b0704186536a51e05832a4a3e1 && git checkout 80040dc7ce7987b0704186536a51e05832a4a3e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b926f76007316ec13d2b17ec4b0a316b94e4d7e 9c632fe74adcbe08c713f7c8f105f85098a72094 && git checkout -B drift-repro 7b926f76007316ec13d2b17ec4b0a316b94e4d7e && git merge --no-ff 9c632fe74adcbe08c713f7c8f105f85098a72094
node scripts/docs-audit/affected-docs.mjs --json 7b926f76007316ec13d2b17ec4b0a316b94e4d7e
|
… security service, pin it on both in-repo kernels, give the reconcile runner an explicit security double (objectstack-ai#22276) Fixes objectstack-ai#22235 Clause-②: no Executes the maintainer's ruling B (`6056824332`): deny, as measured, becomes the declaration. On a kernel that registers no `security` service, the analytics read bridges deny, fail-closed, as they already did. The comments and the test header now say so, a pin holds it on both in-repo kernels, and the one in-repo producer registers an explicit security double. No line that decides an answer moves, on any context. ## What changed - `packages/services/service-analytics/src/plugin.ts`: **comments only.** The resolution blocks of the row-scope bridge (near `:556`), the object-level admission bridge (near `:700`) and the field-level and query-side bridges now give the measured classification. ABSENT is reached only by a context that answers a miss with nothing: the package's test doubles do, and no in-repo kernel does. `ObjectKernel` and `LiteKernel` throw on a never-registered name, so the bridges take UNUSABLE and refuse with a located `error`. The same change is made in the two `catch` comments, the two ABSENT-branch comments, the per-query report comment (the block PR objectstack-ai#22234 last changed) and the two init-time comments. Proof that only comments moved: a comment-stripped transpile (`ts.transpileModule`, `removeComments`) of the base file and of this head is byte-identical (sha256 prefix `c1a4301c498f8087` both sides, 30086 bytes). - `packages/services/service-analytics/src/__tests__/admission-bridge-resolution.test.ts`: - The header is rewritten to the ruling. The sentence that called a deny on absence wrong is gone. - The ABSENT case keeps its assertions. Its title and inline comment now describe the context it actually models, a lookup that answers nothing. - A new pin covers both kernels (`describe.each` over `ObjectKernel` and `LiteKernel`), each booted with no `security` service ever registered. It asserts that the kernel's own lookup throws, and that the dataset query is refused with `code: PERMISSION_DENIED`, `status: 403` and `object: employer_member`. It also asserts that nothing was read, and that exactly one `error` line from the object-level bridge names the object and the failed `security` lookup. Finally, it asserts that the ABSENT branch's WARN did not fire. - `scripts/analytics-reconcile/boot.ts` registers an explicit, OPEN `security` test double as a named plugin. It answers `canReadObject` true, `getReadFilter` undefined (no row restriction) and `getReadableFields` undefined (no answer). The runner compares numbers, not access: its legacy `engine.aggregate()` path runs with no caller context on an engine with no security middleware, so the dataset path gets the same posture, declared by name. ## Hypotheses, measured on `origin/main` `3513ac77` - **H1, confirmed.** The ABSENT rows at `plugin.ts:556` and `:691` stated "admits" and called a kernel shipping no plugin-security a legitimate configuration. The same claim repeated at the two `catch` comments, the two ABSENT-branch comments, the report block, the field-level and query-side rows, and both init-time comments. All are rewritten, comments only. - **H2, done.** The header is rewritten and the pin is added. The pin's ablation is below. - **H3, confirmed, with a refinement.** Driven through `boot.ts`'s own `reconcileApp()` with one dual-form widget (`todo_task` by `status`, `task_metrics` / `task_count`), the composition is denied before this change. The dataset query is refused with `Access denied` and 0 rows, and the bridge logs the UNUSABLE `error`. After the change, the dataset query answers 4 groups whose counts equal the legacy path's (2, 1, 4, 1). Refinement: on `3513ac77` none of the three shipping runners reaches `queryDataset`. `app-todo.ts` boots and skips all 13 widgets and reports as not dual-form, and it exits 0 before and after this change. `app-crm.ts` and `app-showcase.ts` fail `MODULE_NOT_FOUND` on example files that no longer exist (noted below). - **H4, listed, not edited.** The release-owned sentences are listed below for the docs card. - **H5** is raised as an open question in the seat report, not built. ## Pin and ablation (head `183709e70c`) The pin's subject is imported from source (`../plugin.js`), so no `dist` leg applies. - Green: `vitest run` over the two admission files gives 2 files and 13 tests passed. The new pin is 2 cases. - Ablation A1 makes the object-level bridge's `catch` classify a throw as ABSENT. It went through `scripts/ablation-replace.mjs`: anchor x1 to x0, replacement x0 to x1, blob `ccecf120ec1d` to `1d21d50d6cea`, with an outer `trap` restore. - Result: 4 failed, 9 passed. Both new pins (`ObjectKernel`, `LiteKernel`) are red, together with the double-based throw case here and `admission-absence-report.test.ts`'s real-kernel case. - Observed direction: red. The object gate admitted, and the row-scope bridge then refused the same query with `READ_SCOPE_COMPILE_FAILED` / 500, so the declared `PERMISSION_DENIED` / 403 envelope naming the object was lost. - Restore: blob `ccecf120ec1d` equals `HEAD:plugin.ts`, and `git diff HEAD` is empty. ## Local verification (head `183709e70c`) - `@objectstack/service-analytics`: - The build exits 0. - `typecheck` (`tsc --noEmit`) exits 0. The program includes the test file: `--listFiles` lists `admission-bridge-resolution.test.ts`. - `vitest run --maxWorkers=2`: Test Files 180 passed (180). Tests 4443 passed, 262 skipped (4705). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 66 families for this change set. 65 exit 0. `pnpm check:dual-build-cjs-loads` exits 3, `PREREQUISITE NOT MET`, because 37 workspace packages have no `dist/` in this worktree. NOT MEASURED. A targeted substitute reading, a `require()` of this package's `dist/index.cjs`, loads. `dispatch-gates --ran`: 66 derived, 65 run, 1 NOT-MEASURED, 0 UNRUN. - `check:type-check-debt`: 26 raw tsc errors, none above the recorded number. `boot.ts` carries the same 8 `TS2307` it carried before this change, and no other diagnostic. - Lint, narrowed: `eslint --no-inline-config --format json` over the 3 touched files reports 3 files, 0 errors and 0 warnings. The config lints every TS/JS file outside its never-linted list, so all 3 are in scope. It never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on a file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Changeset: none, `skip-changeset` Nothing this package publishes changes behaviour. Its `files` are `dist`, `README.md` and `CHANGELOG.md`. After a rebuild, three of the new comment phrases occur in 0 files under `dist/`, while the positive control (a runtime string from the same file) occurs in 2. The test file and `scripts/` are not published. ## Released sentences that state "absent admits": for the docs card, not edited here `packages/services/service-analytics/CHANGELOG.md`: - `:583-584`: "A deployment with no security service applies no field-level check, as on the data API." - `:645-647`: "A deployment with no security service applies no object-level check, as on the data API." - `:1211-1213`: "A deployment with no security service, and an object the security service gives no field answer for, apply no field-level check, as before." - `:1926`: "a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended." - `:1930`: "an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before)" - `:1936`: "The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`." - `:1952`: "**ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security` ...) ... ⛔ Deliberately not tightened: refusing here would break every such deployment." - `:2773`: "a deployment with NO security service still runs unscoped exactly as before." The same lockstep entry repeats, with the `:1926` and `:1930` sentences, in: - `packages/plugins/plugin-security/CHANGELOG.md:1675` and `:1679` - `packages/spec/CHANGELOG.md:13374` and `:13378` - `packages/verify/CHANGELOG.md:571` and `:575` `content/docs/releases/v17/17-5.mdx:482-484`: "A deployment with no `security` service keeps its old behaviour and warns at init; a registered one that cannot be used denies." ## Acceptance notes - The same declaration repeats outside this card's file surface, in `service-analytics`: - the `read-admission.ts` module header (`:59-65`); - the `admitObjectRead` and `getReadableFields` docs on `AnalyticsServiceConfig` (`analytics-service.ts` near `:842` and `:862`); - the `assertReadAdmitted` doc (near `:1897`); - `field-read-admission.ts:339-341`. Each equates "no provider wired" with "a deployment with no security service". Through the plugin a provider is always wired. Reported to the seat with the docs card's list, not edited here. - On a kernel with no security service, the UNUSABLE `error` line reads "A security service is wired on this deployment". The `assertObjectsReadable` denial line that follows reads "the same verdict GET /data/OBJECT reaches". Neither holds on that deployment (measured through `boot.ts` before this change). These are runtime strings, outside this card's comments-only fence, and are reported to the seat. - `scripts/analytics-reconcile/app-crm.ts` and `app-showcase.ts` import example files that no longer exist (`MODULE_NOT_FOUND`), and `app-todo.ts` has no dual-form widget. So no shipping runner exercises the dataset path. Driving one synthetic dual-form widget showed that the reconcile tool keys a select dimension by stored value on the legacy path and by option label on the dataset path. Counts agree, keys differ. Noted, not filed: no runner reaches it. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22154
Clause-②: no
What changed
packages/services/service-analytics/src/plugin.ts, theadmitObjectReadbridge's absence logging only:warntoinfo, the same level and shape the siblinggetReadScopeline already uses for the identical situation:[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).securitywith nothing): it still returnstrue(admit), exactly as before, and now logs ONE WARN at the first query that finds no service, naming that query's object:[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") .... Once per plugin instance: a closure flag created perinit(), deliberately not a module binding, so a second kernel in the same process gets its own report. The flag records only that the report was made; it caches no verdict, and every query still resolves the service afresh.canReadObjectnorexplain), thegetReadScopebridge, and thetranslateSelectOptionsregion are untouched.Tests (all in
service-analytics): newsrc/__tests__/admission-absence-report.test.ts(5 cases);raw-sql-object-routing.test.tsfollows the moved WARN text;admission-bridge-resolution.test.tsheader comment. One@objectstack/service-analyticspatchchangeset.Why the init-time WARN was always false on a default boot (measured)
@objectstack/plugin-securityregisters thesecurityservice in itsstart():packages/plugins/plugin-security/src/security-plugin.ts:2355, insidestart()(opens at:1646;init()opens at:1461). So at analytics'init()that service can never be present on a composition that ships plugin-security, whatever the plugin order, and the old branch fired on every default boot.PM mechanism hypotheses, measured
7d7943dd: the init-time line wasctx.logger.warnatplugin.ts:1449; thegetReadScopesibling logs atinfoat:1430; theadmitObjectReadclosure built at:754callstrySecurityAdmission()(ctx.getService('security')) on every call.getServicereturns nothing (every in-package test double): the ABSENT branch admits and, before this PR, logged nothing;ObjectKernel,LiteKernel) THROW on a never-registered name ([Kernel] Service 'security' not found), so the bridge takes the UNUSABLE branch: every query is DENIEDPERMISSION_DENIED/ 403 with anerrorline per query, and nothing is read.error; the new once-WARN lives in the ABSENT branch. The divergence between the two classifications is reported to the seat as a separate finding; it is not changed here.pnpm check:startup-registry-verdictandpnpm check:init-service-contractare green on this head. The startup-registry gate is ALSO green on the pre-fix tree, and it lists the analyticssecurityprobe asread-only (legal) [final: no workspace plugin declares providesServices: ['security']]both before and after. It does not see this shape, becauseprovidesServicesis init-only by contract and plugin-security registerssecurityinstart(). Its green is not evidence for or against this fix.Before / after, real kernels, built
distA scratch probe booted a real kernel with a
dataprovider and this plugin, plus optionally a security provider registered after analytics' init, and ran the same dataset query twice. Rows below are admission-related log lines and outcomes.7d7943dd)warnat init; 2 queries admitted, 2 readsinfoat init; 2 queries admitted, 2 readswarnat init; 2 admitted, 2 readsinfoat init; 2 admitted, 2 readswarnat init; per queryerror+ 403, 0 readsinfoat init; per queryerror+ 403, 0 readswarnat init; per queryerror+ 403, 0 readsinfoat init; per queryerror+ 403, 0 readsPins (
admission-absence-report.test.ts)LiteKernel, security registered fromstart(): no admissionwarn/errorat boot; the init line is theinfoabove.canReadObject('employer_member', ctx); an admitted caller gets rows (1 read); a refusing service givesPERMISSION_DENIED/ 403 with 0 reads.LiteKernel, no security service ever: no init-time admission WARN; the query is deniedPERMISSION_DENIED/ 403, 0 reads, with the bridge's existingerrorline (unchanged outcome).employer_member; the second query is admitted and does not repeat it; noerror.Ablations (head
9c632fe74a; mutation throughscripts/ablation-replace.mjswith an anchor that must hit, an outertraprestore, and a blob-hash proof)The subject is imported from source (
../plugin.js), so nodistleg applies. Each leg: anchor x1 to x0, replacement x0 to x1, restored blobfc1df493483aequal toHEAD:plugin.ts,git diff HEADempty.ctx.logger.warn: 6 failed / 8 passed; red on pins 1 to 5 and onraw-sql-object-routing"leaves default-datasource objects exactly where they were".if (true)): 1 failed / 13 passed; red on pin 4 (the second query repeats the WARN).debug: 3 failed / 11 passed; red on pins 4 and 5 and on the sameraw-sql-object-routingcase.Local verification (head
9c632fe74a)pnpm --filter @objectstack/service-analytics build: ok.typecheck(tsc --noEmit, whose program includes the new test file): exit 0.pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: Test Files 179 passed (179); Tests 4415 passed, 262 skipped (4677).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives on9c632fe74a, pluspnpm check:startup-registry-verdictandpnpm check:init-service-contract: 66 of 66 exit 0.dispatch-gates --ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.eslint --no-inline-config --format jsonover the 4 touched source files. The config lints every*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}outside its never-linted list andpackages/spec/**, so all 4 are in scope; the JSON reports 4 files, 0 errors, 0 warnings. The config never enables type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any file it does not touch. The repo-widepnpm lintis left to CI.Acceptance notes
getReadScopebut notadmitObjectRead,securityPresentAtInitis never probed, so the admission init line reads "not registered yet at init" even when a security service is present. Before this PR the same branch was a WARN. The line is nowinfo; the condition is unchanged and is noted here, not changed.analytics-service.ts:1901(assertReadAdmitteddoc) still says "AnalyticsServicePluginreports that state at init." It is now reported at init atinfo, and at the first query atwarn. That file is outside this card's file surface, so it is noted here, not edited.check:startup-registry-verdictcannot see init-time verdicts about a service its provider registers instart()(see H3).Generated by Claude Code