Skip to content

fix(service-analytics): declare the measured deny on a kernel with no security service, pin it on both in-repo kernels, give the reconcile runner an explicit security double - #22276

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22235-analytics-absent-deny-declared
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22235-analytics-absent-deny-declared

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 fix(service-analytics): log the admitObjectRead bridge's init-time security absence at info; WARN once at the first query that finds none #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

claude added 3 commits October 8, 2026 10:36
…eny declaration; pin a never-registered security service on both in-repo kernels

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…d deny on a kernel with no security service

Comments only: the ABSENT branch is reached only by a context that answers a
miss with nothing; the in-repo kernels throw on a never-registered name, so the
bridges take UNUSABLE and refuse, fail-closed. No line that decides an answer
moves (comment-stripped transpile identical to the base).

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…so the dataset path answers

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@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 8cbe255ef6cadf6a38bcf68a49afa43591f88543 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea934414a3c1a9ecd6776ff9af408ada03609cdf — the merge of head 183709e70ca3c45a2916ee4e738820d10e558b9f into base 8cbe255ef6cadf6a38bcf68a49afa43591f88543, 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 ea934414a3c1a9ecd6776ff9af408ada03609cdf && git checkout ea934414a3c1a9ecd6776ff9af408ada03609cdf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8cbe255ef6cadf6a38bcf68a49afa43591f88543 183709e70ca3c45a2916ee4e738820d10e558b9f && git checkout -B drift-repro 8cbe255ef6cadf6a38bcf68a49afa43591f88543 && git merge --no-ff 183709e70ca3c45a2916ee4e738820d10e558b9f

node scripts/docs-audit/affected-docs.mjs --json 8cbe255ef6cadf6a38bcf68a49afa43591f88543

⚠️ 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 8cbe255ef6cadf6a38bcf68a49afa43591f88543 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 8, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 11:50
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 11:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 5870716 Oct 8, 2026
40 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22235-analytics-absent-deny-declared branch October 8, 2026 12:25
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…reads" sentences to the measured deny (objectstack-ai#22296)

Part of objectstack-ai#22279

Clause-②: no

This is the docs-only half of objectstack-ai#22279. It amends release-owned text and
nothing else. The four `service-analytics` source comments are in a
separate PR, on branch `claude/issue-22279-analytics-source-comments`.
The card stays open until both PRs have merged.

## Route: amend each entry in place and add a dated erratum inside it

AGENTS.md, *Documentation Guardrails*, the `packages/*/CHANGELOG.md`
row: "Factual error in a released entry → **amend that entry in a
dedicated docs-only PR**, ⛔ never an erratum in a later entry and never
a rider on code changes". The `content/docs/releases/` row: "Factual
error on a releases page → dedicated docs-only PR or an issue, never a
rider on code changes."

Triage asked this PR to say which route it took: "⛔ No silent rewrite of
a published CHANGELOG line, if that process forbids one; the PR states
which route it took."

The route: each false passage is corrected inside the entry that
published it. Each amended entry also gets one dated line at its own
end, in the form `*Erratum, 2026-10-08 — this entry said "…". …
(Corrected after publication, objectstack-ai#22279.)*`. That line quotes the wording
that shipped. Earlier in-place amendments in these files use the same
shape, for example 5b481e2 and 576afc1 in
`packages/spec/CHANGELOG.md`, and the bulk-callers bullet on the 17.0
release page. So nothing is rewritten silently. The shipped wording is
quoted in the entry, kept in the published tarballs and kept in git
history. No later entry is written, and no changeset is added (see
below).

## Why: maintainer ruling B on objectstack-ai#22235

Ruling `6056824332` (「同意」, 2026-10-08), verbatim: "**B — deny, as
measured, becomes the declaration.** On a deployment with no security
service the analytics read bridges deny, fail-closed, as they do today;
the bridge comments and the header of
`admission-bridge-resolution.test.ts` say so; the "absent admits"
sentence in the released text is corrected by a docs card, since that
text is release-owned."

The sentences were false when they were published, not only now.
Measured on the published tarballs (`npm pack`, unpacked into a scratch
directory):

- `@objectstack/core` 17.5.0 and 17.6.0: the context `getService` of
both `ObjectKernel` and `LiteKernel` throws `[Kernel] Service 'NAME' not
found` for a name that was never registered.
- `@objectstack/service-analytics` 17.5.0 and 17.6.0: the object-level
bridge catches that throw as `unusable` and denies. Its `if
(resolved.kind === "absent") return true;` line is reached only when the
lookup returns nothing. The row-scope bridge does the same and throws
`readScopeUnresolvedError`. In 17.6.0 the field-level bridge throws as
well. In both releases `assertReadAdmitted` runs before
`resolveReadScopes`.

The new text states what was ruled: on a deployment with no security
service the bridges deny, fail-closed. ⛔ It does not say that the ABSENT
branch denies. ABSENT is described as a state reached only by a context
that answers the lookup with nothing, and no in-repo kernel does that.
This matches the bridge comments that PR objectstack-ai#22276 landed on `main`
(`58707166f`).

## What changed

Line positions are at base `58707166f`. These files have not changed
since `3513ac77`, where the card read its positions.

| File | Entry | Passage | Listed on the card? |
|---|---|---|---|
| `service-analytics/CHANGELOG.md` | 17.6.0 `1571aed` | `:583-584`
"applies no field-level check, as on the data API" | yes |
| same | 17.6.0 `5f6b63a` | `:645-647` "applies no object-level check,
as on the data API" | yes |
| same | 17.6.0 `ce4e205` | `:821` "the field-level read gate, which
stands down with no security service" | **added** |
| same | 17.6.0 `ae1e950` | `:1211-1213` "A deployment with no security
service, and an object …, apply no field-level check" | yes |
| same, and the three lockstep copies | 17.5.0 `041d9fd` | `:1917-1919`
BREAKING banner, "Nothing that was already admitted becomes refused
**except** the requests GET /data/OBJECT refuses" | **added** |
| same, and the three lockstep copies | 17.5.0 `041d9fd` | `:1926`
"keeps its previous analytics behaviour by design" | yes |
| same, and the three lockstep copies | 17.5.0 `041d9fd` | `:1930` "an
ABSENT `security` service admits (… keeps a deployment shipping no
`plugin-security` working as before)" | yes |
| same | 17.5.0 `5d12b16` | `:1936` "ABSENT admits, THROWING and
METHOD-LESS deny" | yes |
| same | 17.5.0 `5d12b16` | `:1952` ABSENT row, "a legitimate
configuration … ⛔ Deliberately not tightened" | yes |
| same | 17.5.0 `5d12b16` | `:1955` "and no deployment with none,
changes behaviour by so much as a byte" | **added** |
| same | 17.5.0 `54b3d1d` | `:2773` "a deployment with NO security
service still runs unscoped exactly as before" | yes |
| `content/docs/releases/v17/17-5.mdx` | object read grant bullet |
`:482-486` "keeps its old behaviour and warns at init" | yes
(`:482-484`) |

The lockstep copies are `plugin-security/CHANGELOG.md` (`:1666-1668`,
`:1675`, `:1679`), `spec/CHANGELOG.md` (`:13365-13367`, `:13374`,
`:13378`) and `verify/CHANGELOG.md` (`:562-564`, `:571`, `:575`). After
the edit the `041d9fd` entry is still byte-identical in all four files:
27 lines each, sha256 prefix `a7ff1b99f2de9992`. The four copies were
also identical before the edit.

Each amended entry gets one erratum line: seven in
`service-analytics/CHANGELOG.md`, one in each lockstep copy and one in
the release-page bullet. On the release page, the migration line also
gains the remedy for a deployment with no security service: register
one, or supply `admitObjectRead`.

### The three added passages (bounded in-place fix, declared here)

The card did not list these. Each makes the same claim and sits inside
an entry this PR amends, or in the same file:

- `:1917-1919` (all four copies): on a kernel with no security service,
analytics reads that 17.4 served became refused, while `/data` still
serves them. Left alone, the banner would contradict the amended `:1926`
three paragraphs below it in the same entry. One clause is added.
- `:1955`: the same entry as `:1936` and `:1952`, with the same claim.
- `:821`: the claim `:1211` makes, in another 17.6.0 entry of the same
file.

All three meet the four conditions: the same defect class as the card, a
mechanical rewording to a shape the ruling already fixed, a file this
card already holds, and the same gates. Reviewer: if you want any of
them out, say which and it comes back in a patch round.

## Not changed, on purpose

- The ADR-0087 disposition marker of the `041d9fd` entry (`:1912`) is an
HTML-comment line that does not render. Its prose also says the
narrowing is "the same verdict GET /data/OBJECT already returns". It is
the machine-read disposition record, its category (`not-required`) does
not change, and it is left as published.
- `:1930` still says the plugin "warns loudly at init when no security
service is registered". That stays because 17.5.0 and 17.6.0 did print
that warning at init (measured in both tarballs). Only what the bridge
then does is corrected.
- `:1244` says the 17.6.0 member gate refuses "in every tier — including
a deployment with no security service". That is true, so it stays.
`:1948` describes the value the pre-fix code produced for an absent
service. That is history, so it stays too.
- Not here, per the card: the runtime UNUSABLE log text in `plugin.ts`
and `read-admission.ts`, and any change in behaviour.

## Changeset: none, labelled `skip-changeset`

This follows 5b481e2 and 576afc1. `CHANGELOG.md` is in each
package's `files[]`, so the next tarball ships the amended text. A
changeset is still the wrong tool here. It would compile the correction
into a new entry under a later version. That is the "erratum in a later
entry" the guardrail rules out, and this PR changes no package code.
`changeset version` prepends to these files and keeps the amended
entries. All four packages are in the single `fixed` group of
`.changeset/config.json`, and 101 changesets are pending on `main`, so
the next release carries the corrected entries.

## Verification (head `2bd53e98a`)

- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives **85** commands for this diff. Each
was run with its exit code captured before any pipe. **79 exit 0.** **6
are NOT MEASURED**: each exited 3, `PREREQUISITE NOT MET`, because it
reads build output this docs-only worktree does not hold.
- `pnpm --filter @objectstack/spec run check:skill-examples`: its
client-SDK half needs a 36-package build of the `@objectstack/client`
and `@objectstack/client-react` closure.
  - `pnpm check:i18n`: needs the CLI closure, 61 packages.
- `pnpm check:dts-closure`, `pnpm check:dual-build-cjs-loads`, `pnpm
check:lean-entry-closure` and `pnpm check:sourcemap-no-sources-content`:
need a full workspace build.
- This narrowing is declared. The diff changes no source byte. A build
compiles `src/` and never reads `CHANGELOG.md` or `.mdx`. CI's `Lint &
Repo Gates` runs these gates after its own closure build.
- `node scripts/pm/dispatch-gates.mjs --ran` gives: "85 derived, 79 run,
6 NOT-MEASURED, 0 UNRUN."
- The `@objectstack/lint` closure (lint, formula, spec, sdui-parser) was
built under the verify lock first, so these gates were measured rather
than refused: `check:docs` ("225 generated files in sync"),
`check:doc-formula-expressions`, `check:doc-security-posture` and
`check:docs-transcript-drift`, all exit 0. The build left the working
tree clean.
- Named results: `check:nul-bytes` scanned 10245 tracked files and found
no raw control bytes. `check:release-notes`,
`check:release-page-status`, `check:doc-authoring` and
`check:issue-citations` all exit 0. A separate control-byte grep over
the five files matches nothing.
- Lint: `eslint --no-inline-config --format json` over the 5 files
reports 5 files, each "File ignored because no matching configuration
was supplied". No config object matches `.md` or `.mdx`, so `pnpm lint`
has nothing to judge here.

## Acceptance notes

- Two test titles say "no security service" where they mean a test
double that answers the lookup with nothing.
`admission-absence-report.test.ts:199` names its describe "no security
service ever registered (the ABSENT resolution)".
`caller-member-column-reference-gate.test.ts:109` labels a tier "no
security service (no field reader wired)". Test names are outside a
docs-only PR and outside a comments-only PR. Noted, not filed. Carrier:
none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… a deployment with no security service (objectstack-ai#22299)

Part of objectstack-ai#22279

Clause-②: no

This is the source-comment half of objectstack-ai#22279: comments only, plus the patch
changeset their published text needs. The release-owned text is in the
docs-only PR objectstack-ai#22296. The card stays open until both PRs have merged.

## Why

Maintainer ruling B on objectstack-ai#22235 (`6056824332`, 「同意」, 2026-10-08),
verbatim: "**B — deny, as measured, becomes the declaration.** On a
deployment with no security service the analytics read bridges deny,
fail-closed, as they do today; the bridge comments and the header of
`admission-bridge-resolution.test.ts` say so; the "absent admits"
sentence in the released text is corrected by a docs card, since that
text is release-owned."

PR objectstack-ai#22276 (`58707166f`) rewrote the bridge comments in `plugin.ts` and
the test header to that ruling. The comments here are the ones its
acceptance notes listed outside its fence. Each one equated "no provider
wired" with "a deployment with no security service" and said such a
deployment keeps its analytics behaviour. Through
`AnalyticsServicePlugin` a provider is always wired, either the host's
own or the bridge. On `ObjectKernel` and `LiteKernel` the lookup of a
`security` service that was never registered throws, so the bridges take
UNUSABLE and refuse the query, fail-closed.

The new text follows the `plugin.ts` wording on `main`. ⛔ It does not
say the ABSENT branch denies. ABSENT is described as reached only by a
context that answers the lookup with nothing (the package's test doubles
do, and no in-repo kernel does). Whether ABSENT itself should deny is
not ruled.

## What changed (comments only)

Line positions are at base `58707166f`.

| File | Site | Listed on the card? |
|---|---|---|
| `read-admission.ts` | module header, *Fail direction*, `:51-65`. The
ABSENT bullet is replaced by three bullets: the declared deny on a
deployment with no security service, the ABSENT context, and a host that
constructs `AnalyticsService` with no provider. The lead-in "but closed
is a claim about a WIRED provider" goes. | yes (`:59-65`) |
| `analytics-service.ts` | `AnalyticsServiceConfig.admitObjectRead` doc,
`:842-845` | yes |
| `analytics-service.ts` | `AnalyticsServiceConfig.getReadableFields`
doc, `:862-866` | yes |
| `analytics-service.ts` | `assertReadAdmitted` doc, `:1899-1901` (the
card's `:1897-1899`) | yes |
| `field-read-admission.ts` | `assertCallerMembersJudgeable` header,
`:338-341` | yes (`:339-341`) |
| `analytics-service.ts` | the `[objectstack-ai#20917]` field-level read gate doc,
`:1922` "A no-op when no provider is wired (no security service)". It
documents `assertFieldsReadable` but sits detached, directly above the
member-shape gate's doc. | **added** |
| `analytics-service.ts` | `assertCallerMembersResolvable` doc, `:1935`
"that gate is a no-op with no security service" | **added** |
| `analytics-service.ts` | `assertDatasetFieldsJudgeable` doc, `:2061`
"which stands down with no security service" | **added** |
| `read-scope-refusal.ts` | `readScopeUnresolvedError` doc, `:96-99`: "a
deployment with no security service … is reported loudly at init, and it
must keep running unscoped exactly as before" | **added** |

### The four added sites (bounded in-place fix, declared here)

The card listed four sites, taken from PR objectstack-ai#22276's acceptance notes. A
census of the package's `src/` for the same claim, `git grep -n -i 'no
.?security.? service'` outside `__tests__`, found four more. Three are
in `analytics-service.ts`, a file this card already holds. The fourth is
the row-scope sibling of the read-admission header. All four meet the
four conditions: the same defect class as the card, a mechanical
rewording to a shape the ruling already fixed, the same gates, and no
other claim on the file (`read-scope-refusal.ts` is in none of the 12
open PRs' file lists, which were read during this run before the edit).
Leaving them would keep the claim alive twenty lines below the doc this
PR corrects. Reviewer: if you want any of them out, say which and it
comes back in a patch round.

## Proof that only comments moved

For each of the four files, `ts.transpileModule` with `removeComments:
true` gives byte-identical output for the base file and for this head:

```
read-admission.ts        base=8a4829c31e94d58c/1351   head=8a4829c31e94d58c/1351
analytics-service.ts     base=8dd74af6788b7660/65291  head=8dd74af6788b7660/65291
field-read-admission.ts  base=9be45d2722f380cd/5145   head=9be45d2722f380cd/5145
read-scope-refusal.ts    base=badef6b1c177459d/233    head=badef6b1c177459d/233
```

(sha256 prefix / bytes.) Positive control: the same comparison over
`read-scope-refusal.ts` with `err.status = 500` changed in memory to
`501` reports the stripped outputs as different.

## Changeset: `patch` for `@objectstack/service-analytics`, not
`skip-changeset`

AGENTS.md: published means what `files[]` ships, and this package ships
`["dist","README.md","CHANGELOG.md"]`. After a rebuild of the package
(under the verify lock), the edited JSDoc is in the published output:

- "That is not a deployment with no security service" (the two
`AnalyticsServiceConfig` docs) is in `dist/index.d.ts` and
`dist/index.d.cts`.
- "without `admitObjectRead`. `AnalyticsServicePlugin` always wires one"
and "no-op with no field reader wired" (class-member docs) are in all
four of `dist/index.{js,cjs,d.ts,d.cts}`.
- "a deployment that registers NO security service denies" (the module
header) is in `dist/index.d.ts` and `dist/index.d.cts`.
- Control for that reading: the `read-scope-refusal.ts` phrase "their
lookup throws on the never-registered name" is in 0 files, because a
non-exported function's doc is not emitted. A runtime string from
`plugin.ts` is in 2 files.

The published `@objectstack/service-analytics@17.6.0` tarball carries
the old sentences in the same files. "the deployment has no security
service, which" and "keeps its pre-existing analytics behaviour" are in
its `index.d.ts` and `index.d.cts`. "A no-op when no provider is wired:
that is a deployment with no security" is in all four. So this diff
changes published bytes and takes a `patch`:
`.changeset/22279-analytics-absent-security-comments.md`.

This differs from PR objectstack-ai#22276, which carried `skip-changeset`. Its
`plugin.ts` comments sit inside a function body, measured at 0 files
under `dist/`. Here the comments are on an exported interface and on
class members, which the build keeps.

## Verification (head `7fb9cee98`)

- `@objectstack/service-analytics` was built under the verify lock: its
dependency closure, then the package itself. `check-dts-emitted` found
"2/2 declared declaration file(s) present".
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0.
- `pnpm --filter @objectstack/service-analytics exec vitest run
--maxWorkers=2`: "Test Files 180 passed (180)", "Tests 4443 passed | 262
skipped (4705)". These are the same counts PR objectstack-ai#22276 recorded.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives **58** commands. Each was run with
its exit code captured before any pipe. **57 exit 0.** **1 is NOT
MEASURED**: `pnpm check:dual-build-cjs-loads` exited 3, `PREREQUISITE
NOT MET`, because 65 workspace packages have no `dist/` in this
worktree. A targeted substitute reading: `require('./dist/index.cjs')`
of this package loads, with 17 exports and `AnalyticsServicePlugin` a
function. `dispatch-gates --ran` gives: "58 derived, 57 run, 1
NOT-MEASURED, 0 UNRUN."
- Named results: `check:dts-closure` reports 75/75 declaration files
across 15 built packages. `check:sourcemap-no-sources-content` reports
105 maps across 15 packages, none embedding source.
`check:published-files`, `check:nul-bytes` (10246 tracked files, no raw
control bytes), `check:doc-authoring`, `check-empty-changeset` ("1
declaring changeset(s) added") and `check:lean-entry-closure` all exit
0. Locally, `check-changeset-no-major` reads its clause-② level axis as
not applicable, because there is no `pull_request` payload. CI reads
this body's `Clause-②: no`.
- Lint, narrowed: `eslint --no-inline-config --format json` over the 4
touched source files reports 4 files, 0 errors and 0 warnings. The
config's only global ignores are `node_modules`, `dist`, `build`,
`.next` and `.turbo`, so all 4 files are in scope. The config never
enables type-aware linting (no `parserOptions.project` in
`eslint.config.mjs`), so this diff cannot change the verdict on a file
it does not touch. The repo-wide `pnpm lint` is left to CI.

## Acceptance notes

- Not here, per the card: the runtime UNUSABLE log text ("A security
service is wired on this deployment") in `plugin.ts` and
`read-admission.ts`, and any change in behaviour.
- Two test titles still say "no security service" for a double that
answers the lookup with nothing. One is the describe at
`admission-absence-report.test.ts:199`, the other the tier label at
`caller-member-column-reference-gate.test.ts:109`. Test names are
outside this comments-only fence. Noted, not filed. Carrier: none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_

---------

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

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants