Skip to content

Commit 24e3579

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-13457-plugin-permission-load-gate
2 parents 0ec3026 + 5d12b16 commit 24e3579

11 files changed

Lines changed: 909 additions & 113 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918)
6+
7+
`AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead``canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope``getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one:
8+
9+
```ts
10+
const trySecurity = () => {
11+
try {
12+
const svc = ctx.getService<SecurityReadFilter>('security');
13+
return svc && typeof svc.getReadFilter === 'function' ? svc : undefined;
14+
} catch { return undefined; }
15+
};
16+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
17+
```
18+
19+
A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy.
20+
21+
**What changes.** The bridge now resolves the same explicit three-way, at the same reporting level:
22+
23+
- **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`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment.
24+
- **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal.
25+
26+
**This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted.
27+
28+
**No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window.
29+
30+
⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to.
6+
7+
`packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649).
8+
9+
The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger.
10+
11+
The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named.
12+
13+
No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited.

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
8686
- 收班简报是前任不再写的显式声明,是释放标记不是锁:简报即最新事件 ⇒ 立即坐席。
8787
- 滞后标题是进场顺手修的半状态,⛔ 不是阻塞;简报点名的留守尾巴作围栏。
8888
- 维护者明示召唤是仲裁:有简报径直坐席;无简报才走保守确认,确认终止即坐席。
89-
- 互斥清 ⇒ 先在座位贴留一行开轮标记(session ID + fire 时刻)再跑轮。
89+
- 互斥清 ⇒ fetch 后读三章程文件(本文、core-rules、本席章程)在 `origin/main` 的最新触碰 sha。
90+
- 异于上一开轮标记即先重读;留开轮标记(session ID + fire 时刻 + 该触碰,注明重读)再跑轮。
9091

9192
## 全体座位的不变量
9293

@@ -283,7 +284,6 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
283284
- 平台事实变化 → references 事实表改一行。
284285
- 三类以 `finding` 入 skills 车道由该席分诊;三类之外默认关 not planned。
285286
- 经验教训散文不再入技能文本;交接按收尾清单逐步走完,并 `list_triggers` 清点自设定时器。
286-
- 归档自己派出的会话是不可移交的义务。
287287
- 四段模板、状态词表、接管/退场收尾清单细则见 `references/seat-post-protocol.md`
288288
- epic 委托不入座位贴体系;`packages/spec` 恒归 spec 座位。
289289

.claude/skills/pm-dispatch/references/core-rules.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- 自设定时器只定下次唤醒、不定轮长;新 fire 到时上一轮未清空即接续,⛔ 不重开不重排。
1818
- 分诊席跑普通直连会话,达档职责改派显式传档位的子代理;自排下次唤醒不超过 55 分钟。
1919
- 开轮互斥读四个读数:收班简报、开轮标记、本车道最新他会话认领、最新关闭卡的认领。
20-
- 未收班活动任一未满一轮即自退,总监席除外;有收班简报即坐席,坐席后先写开轮标记
20+
- 未收班活动任一未满一轮即自退,总监席除外;有简报即坐席,坐席后核章程再写开轮标记
2121
## 全体座位的不变量
2222
- 状态住 GitHub 标签、assignee、正文行与座位贴;⛔ 本地零状态;循环须能从新会话恢复。
2323
- PM ⛔ 不写文件也不写代码,唯一例外是维护者逐 PR 明示授权的 `.claude/` 内部工具 PR。

content/docs/references/api/error-code-ledger.mdx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,32 @@ question.
110110
ONE shape, no second list: a `door: 'none'` code is a row like any other —
111111
the string under the package that stamps it, and a comment that states its
112112
`status` and the reachability reading ("no HTTP door on this tree; the
113-
thrown value is the boundary"). The dispatcher vocabulary's `boot-refusal`
114-
verdict (`packages/runtime/src/dispatcher-error-vocabulary.ts`) records
115-
that same reachability for the codes NOT yet registered, and a row here
116-
ratchets its vocabulary row out exactly as a `pending-registration`
117-
registration does. What registration changes for such a code is the face,
113+
thrown value is the boundary"). Until #16649 the dispatcher vocabulary
114+
(`packages/runtime/src/dispatcher-error-vocabulary.ts`) carried a
115+
`boot-refusal` verdict that recorded that same reachability for the codes
116+
NOT yet registered; it is RETIRED, because the gate below now refuses it —
117+
and every verdict like it — under any published package's `src/`. The one
118+
verdict that still parks a site there is `pending-registration`, and only
119+
outside `packages/spec/src/**`, under the dated allowance spelled out below.
120+
So a `door: 'none'` code has no resting place short of a row here, and the
121+
ratchet is the one `pending-registration` already had: a row here makes the
122+
site vanish from that scan. What registration changes
123+
for such a code is the face,
118124
not the wire: nothing demotes today, and if a door ever does answer with
119125
it, `error.code` carries the specific code instead of the status-derived
120126
member plus `declaredCode`. The `declaredCode` demotion (#9106) stays for
121127
genuinely unknown / third-party spellings only.
122128

123-
`packages/spec/src/**` is held to this mechanically:
124-
`check:dispatcher-error-vocabulary` refuses to classify a stamp site under
125-
that tree as anything but `foreign-vocabulary` (a different vocabulary that
126-
merely spells itself `code`) or `runtime-pinned` — a `boot-refusal` or
127-
`pending-registration` row for a spec site is a finding
128-
(`spec-face-unregistered`), and the only way out is the row here.
129+
EVERY published package's `src/**` is held to this mechanically — #16649
130+
widened the rule from `packages/spec/src/**` alone, which is all #16449
131+
could afford to measure. `check:dispatcher-error-vocabulary` refuses to
132+
classify a stamp site under one as anything but `foreign-vocabulary` (a
133+
different vocabulary that merely spells itself `code`) or `runtime-pinned`,
134+
and the only way out is the row here. Under `packages/spec/src/**` that is
135+
the whole rule, so a `pending-registration` row for a spec site is a finding
136+
(`spec-face-unregistered`); outside it, a `pending-registration` row keeps a
137+
named, dated allowance owed to #8846 and any OTHER verdict is a finding
138+
(`published-face-unregistered`).
129139

130140
A code emitted by several packages is listed once per emitting package —
131141
the union dedupes; the per-package rows are provenance, not identity.

packages/runtime/src/dispatcher-error-vocabulary.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,27 @@ export type CodeStampShape =
186186
*/
187187
export type CodeDoor = 'dispatcher' | 'rest' | 'plugin-route' | 'none';
188188

189+
/**
190+
* [#16649] `'boot-refusal'` was HERE, and is retired. It named a refusal raised
191+
* before any HTTP boundary exists — the CLI rethrows it and aborts — and until
192+
* #16404 the ledger ratified that class as not owed a row
193+
* (`MONGODB_MULTI_TENANT_UNSUPPORTED` was UNregistered by #8035 on "host boot
194+
* matching is not wire vocabulary"). #16404 deleted the exemption (the ledger
195+
* is the published face, door or no door), which left the verdict meaning only
196+
* "a registration this tree still owes" — #16449 discharged nine of those,
197+
* #16649's first half the remaining fourteen, and the second half widened
198+
* `check-dispatcher-error-vocabulary`'s face refusal from `packages/spec/src/`
199+
* to every published package's `src/`, which is what makes the verdict
200+
* unwritable: a row carrying it inside that face is now a
201+
* `published-face-unregistered` finding, and the whole scan population lives
202+
* inside it. A union member no site can legally carry is a place to park work,
203+
* so it comes out with the widening that closed it.
204+
*
205+
* ⛔ Do not reintroduce it for the next pre-HTTP producer the scan finds. Its
206+
* answer under #16404 is the ledger row, and the gate's `unclassified-site`
207+
* text says so. REACHABILITY is still recorded — on the ledger row, as the
208+
* `door: 'none'` reading each of the twenty-three registrations carries.
209+
*/
189210
export type CodeVerdict =
190211
/**
191212
* Reaches a wire and the ledger does not know it. Since #9106 the door
@@ -209,28 +230,6 @@ export type CodeVerdict =
209230
* draw the same line for field-level and diagnostic codes).
210231
*/
211232
| 'foreign-vocabulary'
212-
/**
213-
* A refusal raised before any HTTP boundary exists — the CLI rethrows it
214-
* and aborts. Until #16404 the ledger's own note ratified this class as
215-
* NOT owed a row (`MONGODB_MULTI_TENANT_UNSUPPORTED` was UNregistered by
216-
* #8035 on "host boot matching is not wire vocabulary").
217-
*
218-
* [#16404] That exemption is gone: the published face is the ledger, and
219-
* every code that ships in `dist` is registered there, door or no door.
220-
* This verdict now records REACHABILITY only — no door answers with the
221-
* code, so registering it changes no HTTP body — and a row carrying it is
222-
* a registration OWED, exactly like `pending-registration`: the row
223-
* ratchets out when the ledger row lands (#16449 took the nine measured on
224-
* that card's tree; #16649 took the fourteen that remained, so on this
225-
* tree no row carries this verdict — it stays declared for the next
226-
* pre-HTTP producer the scan finds, until the gate's spec-face refusal is
227-
* widened to every published package and this verdict retires with it;
228-
* see the running log below). ⛔ Under `packages/spec/src/**` the gate refuses
229-
* this verdict outright (`spec-face-unregistered`): a spec stamp site is a
230-
* ledger member, a foreign vocabulary or a runtime-pinned template —
231-
* nothing between.
232-
*/
233-
| 'boot-refusal'
234233
/**
235234
* [#9223] The site builds its code by INTERPOLATION, so no source scan can
236235
* say which codes it produces or whether they are registered — and a named
@@ -320,10 +319,21 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
320319
// `WALLED_MEMBERSHIP_POLICY_UNDECLARED` (`@objectstack/organizations`) —
321320
// were registered under their stamping packages and ratcheted out
322321
// (`stale-row`), the reachability each row recorded now carried on its
323-
// ledger row. On this tree NO row carries the `boot-refusal` verdict; a
324-
// future pre-HTTP producer the scan finds still lands here as an
325-
// `unclassified-site` and takes one, then a registration, then comes out
326-
// again. ──
322+
// ledger row.
323+
//
324+
// ── [#16649, second half] The class is now closed MECHANICALLY rather than
325+
// by having been emptied once. `check-dispatcher-error-vocabulary`'s face
326+
// refusal, which #16449 could only afford over `packages/spec/src/`, covers
327+
// every published package's `src/` — the whole of this scan's population on
328+
// this tree (52 sites: 47 published-face, 5 spec-face) — so a site here is
329+
// a ledger member, a `foreign-vocabulary` or a `runtime-pinned` template
330+
// and nothing else, and the `boot-refusal` verdict that used to park one
331+
// between is retired from `CodeVerdict` above. The single carve-out is
332+
// `pending-registration` outside `packages/spec/src/`, granted 2026-09-08
333+
// and owed to #8846; the gate's header dates it and every run prints how
334+
// many rows still stand on it. A future pre-HTTP producer the scan finds
335+
// lands here as an `unclassified-site` and its way out is the ledger row,
336+
// not a row here. ──
327337

328338
// ── runtime-pinned: an interpolated family, checked where it can be ─────
329339
{

packages/services/service-analytics/src/__tests__/admission-bridge-resolution.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ async function bootAnalytics(security?: () => unknown) {
113113
return { service: registered.analytics as AnalyticsService, reads, error };
114114
}
115115

116+
/**
117+
* A working security service's ROW-SCOPE half, carried by every double below
118+
* that is meant to represent one.
119+
*
120+
* `getReadFilter` is a REQUIRED member of `ISecurityService`, and since #16918
121+
* the ROW-SCOPE bridge in the same `plugin.ts` refuses the query when the
122+
* registered service does not expose it — the sibling three-way of the one
123+
* this file measures. `undefined` is that method's documented answer for "no
124+
* row restriction on this object", so a double carrying it stays minimal AND
125+
* conforming, and every object-level verdict asserted below is reached exactly
126+
* as it was before. The deny-path doubles need none: the object-level gate runs
127+
* first and refuses before the row half is ever asked.
128+
*/
129+
const rowScopeOpen = { getReadFilter: async () => undefined };
130+
116131
const runProbe = (service: AnalyticsService) =>
117132
service.queryDataset(probe as never, { measures: ['cnt'] } as never, CALLER);
118133

@@ -170,7 +185,7 @@ describe('analytics admission bridge — resolving the "security" service', () =
170185

171186
it('asks canReadObject when the service has it, and serves an ADMITTED caller', async () => {
172187
const canReadObject = vi.fn(() => true);
173-
const { service, reads } = await bootAnalytics(() => ({ canReadObject }));
188+
const { service, reads } = await bootAnalytics(() => ({ ...rowScopeOpen, canReadObject }));
174189

175190
const result = await runProbe(service);
176191
expect(result.rows).toEqual([{ cnt: 24 }]);
@@ -190,6 +205,7 @@ describe('analytics admission bridge — resolving the "security" service', () =
190205

191206
it('falls back to explain for a service that predates canReadObject — both verdicts', async () => {
192207
const admitted = await bootAnalytics(() => ({
208+
...rowScopeOpen,
193209
explain: async () => ({ allowed: true }),
194210
}));
195211
expect((await runProbe(admitted.service)).rows).toEqual([{ cnt: 24 }]);

0 commit comments

Comments
 (0)