Skip to content

Commit 98cce87

Browse files
docs(releases): amend the released "absent security admits analytics reads" sentences to the measured deny (#22296)
Part of #22279 Clause-②: no This is the docs-only half of #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, #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 #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 #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>
1 parent 799eb00 commit 98cce87

5 files changed

Lines changed: 68 additions & 33 deletions

File tree

‎content/docs/releases/v17/17-5.mdx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -480,10 +480,16 @@ rewrites such a cell for you.
480480
the driver's raw `execute()` behind the row-scope layer only. The service now
481481
asks the new optional `ISecurityService.canReadObject(object, context)` for
482482
the base object and every joined object before choosing a strategy. A
483-
deployment with no `security` service keeps its old behaviour and warns at
484-
init; a registered one that cannot be used denies. **Migration:** a principal
485-
now refused needs object-level read on that object — the grant `/data`
486-
already requires.
483+
registered `security` service that cannot be used denies, and so does a
484+
deployment with no `security` service: the kernel's lookup throws, so the
485+
query is refused, fail-closed, although `/data` has no object-level gate
486+
there. **Migration:** a principal now refused needs object-level read on that
487+
object — the grant `/data` already requires; a deployment with no `security`
488+
service registers one, or its host supplies its own `admitObjectRead`.
489+
*Erratum, 2026-10-08 — this bullet said "A deployment with no `security`
490+
service keeps its old behaviour and warns at init". In the published 17.5.0
491+
packages the kernel's lookup throws on a `security` service nothing ever
492+
registered, so analytics refused the query, fail-closed (#22279).*
487493
- **The saved-report stack is removed** (`8d1f7ab`, #20125). Its eight routes
488494
answer the standard unmatched-route `404`; nine `REPORTS_*` / `SCHEDULE_*`
489495
error codes leave the ledger; `defineStack` refuses `requires: ['reports']`

‎packages/plugins/plugin-security/CHANGELOG.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,21 +1665,24 @@
16651665
lockstep convention: during the window the bump level is not the carrier, this
16661666
banner and the disposition above are). Nothing that was already admitted
16671667
becomes refused **except** the requests `GET /data/<object>` refuses today for
1668-
the same principal, which is the defect. Nothing that was refused becomes
1669-
admitted.
1668+
the same principal, which is the defect, and every analytics read on a
1669+
deployment that registers no `security` service (below). Nothing that was
1670+
refused becomes admitted.
16701671

16711672
`POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/<object>` reach one admission verdict on every driver.
16721673

16731674
The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one.
16741675

1675-
**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: 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.
1676+
**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. A deployment with no `security` service registered is refused too, fail-closed: `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, so the bridge below gets no verdict and denies, naming the object. `/data` carries no object-level gate on that deployment, so there analytics refuses reads that `/data` serves. A composition that wants analytics to answer registers a security service, or its host supplies its own `admitObjectRead`.
16761677

16771678
- **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional.
16781679
- **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service.
1679-
- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: 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); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states.
1680+
- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: a context that answers the `security` lookup with nothing (ABSENT) admits, and no in-repo kernel answers that way; a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`. Resolving it throws on `ObjectKernel` and `LiteKernel` when no `security` service was ever registered, so a deployment that ships no `plugin-security` (or any other security service) is refused here, although `/data` carries no object-level gate on it; for a wired service that cannot be used, `/data`'s middleware does not fall open either.
16801681
- **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree.
16811682

16821683
The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named.
1684+
1685+
*Erratum, 2026-10-08 — this entry said that a deployment with no `security` service registered "keeps its previous analytics behaviour by design", that "an ABSENT `security` service admits", and that this "keeps a deployment shipping no `plugin-security` working as before". The sentences were false when published: in the published 17.5.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, so the bridge took its cannot-be-USED branch and refused the query, fail-closed. ABSENT is reached only by a context that answers the lookup with nothing, and no in-repo kernel does. Two passages above are corrected in place and the BREAKING banner now names that deployment among the requests that become refused; everything else this entry published is unchanged. (Corrected after publication, #22279.)*
16831686
- 2266438: Re-run the seed-ownership claim when the seed settles, and report whether each pass was final.
16841687

16851688
`claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised.

0 commit comments

Comments
 (0)