Skip to content

fix(service-analytics): log the admitObjectRead bridge's init-time security absence at info; WARN once at the first query that finds none - #22234

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22154-analytics-security-absence-info
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22154-analytics-security-absence-info

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22154

Clause-②: no

What changed

packages/services/service-analytics/src/plugin.ts, the admitObjectRead bridge's absence logging only:

  • Init-time line: warn to info, the same level and shape the sibling getReadScope line 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).
  • Per-query bridge, ABSENT branch (the plugin context answers a lookup for security with nothing): it still returns true (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 per init(), 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.
  • The UNUSABLE branch (lookup threw, or the service answers neither canReadObject nor explain), the getReadScope bridge, and the translateSelectOptions region are untouched.

Tests (all in service-analytics): new src/__tests__/admission-absence-report.test.ts (5 cases); raw-sql-object-routing.test.ts follows the moved WARN text; admission-bridge-resolution.test.ts header comment. One @objectstack/service-analytics patch changeset.

Why the init-time WARN was always false on a default boot (measured)

@objectstack/plugin-security registers the security service in its start(): packages/plugins/plugin-security/src/security-plugin.ts:2355, inside start() (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

  • H1, confirmed at 7d7943dd: the init-time line was ctx.logger.warn at plugin.ts:1449; the getReadScope sibling logs at info at :1430; the admitObjectRead closure built at :754 calls trySecurityAdmission() (ctx.getService('security')) on every call.
  • H2, refined. What the per-query bridge does today when no security service resolves depends on how the context answers the miss:
    • a context whose getService returns nothing (every in-package test double): the ABSENT branch admits and, before this PR, logged nothing;
    • the in-repo kernels (ObjectKernel, LiteKernel) THROW on a never-registered name ([Kernel] Service 'security' not found), so the bridge takes the UNUSABLE branch: every query is DENIED PERMISSION_DENIED / 403 with an error line per query, and nothing is read.
    • This PR keeps both outcomes exactly. On the in-repo kernels, absence at query time was and stays loud at 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.
  • H3, green, with a caveat. pnpm check:startup-registry-verdict and pnpm check:init-service-contract are green on this head. The startup-registry gate is ALSO green on the pre-fix tree, and it lists the analytics security probe as read-only (legal) [final: no workspace plugin declares providesServices: ['security']] both before and after. It does not see this shape, because providesServices is init-only by contract and plugin-security registers security in start(). Its green is not evidence for or against this fix.
  • H4, pinned and ablated (below).

Before / after, real kernels, built dist

A scratch probe booted a real kernel with a data provider 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.

kernel, security before (7d7943dd) after (this head)
ObjectKernel, registered after init warn at init; 2 queries admitted, 2 reads info at init; 2 queries admitted, 2 reads
LiteKernel, registered after init warn at init; 2 admitted, 2 reads info at init; 2 admitted, 2 reads
ObjectKernel, never registered warn at init; per query error + 403, 0 reads info at init; per query error + 403, 0 reads
LiteKernel, never registered warn at init; per query error + 403, 0 reads info at init; per query error + 403, 0 reads

Pins (admission-absence-report.test.ts)

  1. Default composition on a real LiteKernel, security registered from start(): no admission warn/error at boot; the init line is the info above.
  2. Same composition: the first query asks canReadObject('employer_member', ctx); an admitted caller gets rows (1 read); a refusing service gives PERMISSION_DENIED / 403 with 0 reads.
  3. Real LiteKernel, no security service ever: no init-time admission WARN; the query is denied PERMISSION_DENIED / 403, 0 reads, with the bridge's existing error line (unchanged outcome).
  4. ABSENT resolution (context answers nothing): no WARN at init; the first query is admitted and WARNs once, naming employer_member; the second query is admitted and does not repeat it; no error.
  5. Per plugin instance: two separate inits each get their own single report.

Ablations (head 9c632fe74a; mutation through scripts/ablation-replace.mjs with an anchor that must hit, an outer trap restore, and a blob-hash proof)

The subject is imported from source (../plugin.js), so no dist leg applies. Each leg: anchor x1 to x0, replacement x0 to x1, restored blob fc1df493483a equal to HEAD:plugin.ts, git diff HEAD empty.

  • A1, the init line back to ctx.logger.warn: 6 failed / 8 passed; red on pins 1 to 5 and on raw-sql-object-routing "leaves default-datasource objects exactly where they were".
  • A2, once-guard removed (if (true)): 1 failed / 13 passed; red on pin 4 (the second query repeats the WARN).
  • A3, the query-time WARN demoted to debug: 3 failed / 11 passed; red on pins 4 and 5 and on the same raw-sql-object-routing case.

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).
  • Gates: the 64 families node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives on 9c632fe74a, plus pnpm check:startup-registry-verdict and pnpm check:init-service-contract: 66 of 66 exit 0. dispatch-gates --ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config --format json over the 4 touched source files. The config lints every *.{ts,tsx,mts,cts,js,jsx,mjs,cjs} outside its never-linted list and packages/spec/**, so all 4 are in scope; the JSON reports 4 files, 0 errors, 0 warnings. The config never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any file it does not touch. The repo-wide pnpm lint is left to CI.

Acceptance notes

  • When a host supplies getReadScope but not admitObjectRead, securityPresentAtInit is 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 now info; the condition is unchanged and is noted here, not changed.
  • analytics-service.ts:1901 (assertReadAdmitted doc) still says "AnalyticsServicePlugin reports that state at init." It is now reported at init at info, and at the first query at warn. That file is outside this card's file surface, so it is noted here, not edited.
  • check:startup-registry-verdict cannot see init-time verdicts about a service its provider registers in start() (see H3).

Generated by Claude Code

claude added 3 commits October 8, 2026 07:01
…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>
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via AnalyticsServicePlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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): node scripts/docs-audit/affected-docs.mjs --json 7b926f76007316ec13d2b17ec4b0a316b94e4d7e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 80040dc7ce7987b0704186536a51e05832a4a3e1 — the merge of head 9c632fe74adcbe08c713f7c8f105f85098a72094 into base 7b926f76007316ec13d2b17ec4b0a316b94e4d7e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7b926f76007316ec13d2b17ec4b0a316b94e4d7e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 08:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 08:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 036bf1d Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22154-analytics-security-absence-info branch October 8, 2026 09:33
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants