From b084f1003d1cc2e83f9ef1951b4d85ccb3538763 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:39:48 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(spec):=20ISharingService=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=A4=B4=E6=8C=89=20ADR-0111=20D3=20=E6=8B=86?= =?UTF-8?q?=E5=87=BA=20canDelete()=20=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块头的 "Per-record gating" 一条写于 D3 拆分之前,仍称 `canEdit()` 回答 `update` / `delete`,与同文件 140 行之下的 `canDelete()` 文档 直接矛盾 —— D3 的动词边界是:share 只放宽可及的行,不放宽可用的动词, delete = ownership(写 DEPTH 放宽)或 `modifyAllRecords` 旁路,`edit` share 不授予。模块头是跨包调用方进文件读到的第一段,照它做会把 `edit` share 当成删除授权,或以为 `canDelete` 不存在。 改为一条两门:`canEdit()` 管 `update`,`canDelete()` 管 `delete` 且 显式更窄,措辞与下方两个方法文档对齐。纯注释,无 schema/类型/行为变化。 同文件已有 #5125 / #5858 的散文钉先例,补一枚同款轻量钉:读文件级 leading comment(而非 interface 成员文档),断言模块头点名 `canDelete()`,且 `canEdit()` 那一段不再声称 `delete` 动词;两条 反空断言防止改写把断言掏空。 Fixes objectstack-ai/objectstack#5817 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../src/contracts/sharing-service.test.ts | 59 +++++++++++++++++++ .../spec/src/contracts/sharing-service.ts | 12 +++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts index 5e54064738..48fa7f7e14 100644 --- a/packages/spec/src/contracts/sharing-service.test.ts +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -257,3 +257,62 @@ describe('[#5858] HierarchyScopeContext tenancy authority', () => { expect(resolver.get('resolveOwnerIds')).toContain('never widen'); }); }); + +/** + * [#5817] The MODULE HEADER must route each write verb to its own gate. + * + * The header's "Per-record gating" item predates ADR-0111 D3 and still said + * `canEdit()` answered `update` / `delete` — the exact semantics D3 rejects, + * contradicting the `canDelete()` doc 140 lines below it (delete is ownership + * or `modifyAllRecords` ONLY; an `edit` share widens which ROWS a principal + * reaches, never which VERBS). The header is the first thing a cross-package + * caller reads, so following it meant treating an `edit` share as delete + * authorization, or not knowing `canDelete` exists. + * + * A prose pin like #5125's, for the same reason: nothing type-checks a doc + * comment, and the drift is only visible against the method docs a reader may + * never scroll to. This pins the FILE-level comment rather than a member's, so + * it reads the leading comment ranges instead of an interface member. + */ +describe('[#5817] ISharingService module header — one gate per write verb', () => { + it('routes `update` to canEdit() and `delete` to canDelete(), not both to canEdit()', async () => { + const ts = (await import('typescript')).default; + const { readFileSync } = await import('node:fs'); + const { dirname, resolve } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const file = resolve(dirname(fileURLToPath(import.meta.url)), 'sharing-service.ts'); + const text = readFileSync(file, 'utf8'); + + // The module header is one of the comments preceding the first statement; + // it identifies itself by the module path it documents (the copyright line + // and the first type's own doc are the other two). + const header = (ts.getLeadingCommentRanges(text, 0) ?? []) + .map((range) => text.slice(range.pos, range.end)) + .find((comment) => comment.includes('@objectstack/spec/contracts/sharing-service')); + expect(header, 'the module header must still open this file').toBeDefined(); + // Anti-vacuity 1: this really is the header with its numbered concerns, so + // a rewrite cannot empty the assertions below by moving the text elsewhere. + expect(header).toContain('Per-record gating'); + + // THE pin: the header names the gate ADR-0111 D3 split `delete` out to. + // Restoring the pre-D3 sentence turns this red. + expect(header).toContain('canDelete()'); + + // ...and does not hand `delete` back to `canEdit()`. Both indices resolve + // in order, so the slice is never empty (a vacuous pass). + const editAt = header!.indexOf('canEdit()'); + const deleteAt = header!.indexOf('canDelete()'); + expect(editAt, 'canEdit() must still be named in the header').toBeGreaterThan(-1); + expect(deleteAt, 'canDelete() must be named AFTER canEdit()').toBeGreaterThan(editAt); + expect( + header!.slice(editAt, deleteAt), + "canEdit()'s clause must not claim the `delete` verb (ADR-0111 D3)", + ).not.toMatch(/\bdelete\b/); + + // Anti-vacuity 2: the negative above DISCRIMINATES — the lower-case verb is + // still in the header, now attributed to `canDelete()`, so the assertion + // cannot pass by `delete` having disappeared from the file altogether. + expect(header).toMatch(/\bdelete\b/); + }); +}); diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 256221a6f2..49afe271c3 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -12,9 +12,15 @@ * engine middleware AND-s into every read query. Callers must * treat `null` as "object is public, do not filter". * - * 2. **Per-record gating** — `canEdit()` answers the access question - * for `update` / `delete` operations. Returns `true` when the - * caller may modify the record, `false` otherwise. + * 2. **Per-record gating** — two SEPARATE gates, one per write verb. + * `canEdit()` answers the access question for `update`: ownership + * (widened by write DEPTH), a write-level (`edit`) share, or the + * `modifyAllRecords` super-user bypass. `canDelete()` answers it + * for `delete` and is deliberately NARROWER (ADR-0111 D3) — a + * share widens which ROWS a principal reaches, never which VERBS + * they may use, so an `edit` share does NOT confer delete: + * ownership or the bypass only. Each returns `true` when the + * caller may perform that operation, `false` otherwise. * * Manual share CRUD is exposed via `grant()`, `revoke()`, and * `listShares()`. The REST layer wires these to From e223303735ad5fa634cfa4d1f2da871bc0c69ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 23:50:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E9=87=8D=E8=A7=A6=E5=8F=91=20CI?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E5=B9=B3=E5=8F=B0=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=9A=84=20rerun=20attempt=20=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=E9=A2=84=E6=8E=92=E9=98=9F,=E6=8D=A2=20head=20?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test.yml 的 rerun attempt 落在 GitHub 平台 20:01Z 冻结窗口内,卡在预排队 limbo:409 不可取消、零 job 实例化。调度层已恢复但只对新触发生效,故用空提交 换 head 重新调度。 无任何文件改动(--allow-empty),不含 merge / rebase。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY