From 9d36691521aa409508a042ddb0c99220bd19780b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:01:49 +0000 Subject: [PATCH 1/4] =?UTF-8?q?chore(spec,dogfood):=20bind=20permission.ta?= =?UTF-8?q?bPermissions=20to=20the=20me-apps=20proof=20=E2=80=94=20after?= =?UTF-8?q?=20making=20the=20proof=20author=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The me-apps dogfood test only MENTIONED tabPermissions in its header while exercising the route and app.requiredPermissions — binding then would have been the owner-anchor/allowTransfer mistake the proof-registry warns about (a proof cited for a property it never authors). It now authors the property on the PERMISSION-SET side: a set whose only content is tabPermissions:{showcase_app:'hidden'} drops the app from /me/apps for its holder, a second more-visible grant wins it back (tabRank most-visible-wins), and unrelated principals are asserted untouched. Registry: app-tab-permissions bound → permission/tabPermissions (the governed surface; app remains ungoverned). Ledger entry carries the proof ref. README proof tables updated. Dogfood file: 6/6 green against a booted stack; liveness gate + 88 registry tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- ...apps-and-everyone-baseline.dogfood.test.ts | 59 +++++++++++++++++++ packages/spec/liveness/README.md | 2 +- packages/spec/liveness/permission.json | 3 +- .../spec/scripts/liveness/proof-registry.mts | 18 ++++-- .../scripts/liveness/proof-registry.test.ts | 4 ++ 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index 540ed3c7a1..a06254babc 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -67,6 +67,65 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { expect(body?.apps ?? []).toEqual([]); }); + // ── permission.tabPermissions authored end-to-end ───────────────────────── + // Added by the 2026-07 security-subset liveness re-verification. Until then + // this file only MENTIONED tabPermissions in its header while exercising the + // route and requiredPermissions — and per ADR-0054, binding a ledger entry to + // a proof that does not author the property is the preview-renderer mistake + // in a nicer costume. This authors the property on a permission set, both + // directions of the rank merge included. + it('a permission set hiding an app via tabPermissions drops it from /me/apps — and a more-visible grant wins it back', async () => { + const tabTok = await stack.signUp('tab-member@verify.test'); + const tabUser = await ql.findOne('sys_user', { where: { email: 'tab-member@verify.test' }, context: SYS }); + expect(tabUser?.id, 'dedicated tab-probe member resolved').toBeTruthy(); + + // Control: before any tabPermissions grant, the member sees the app. + const before = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const beforeNames = (((await before.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); + + // Author the property: a set whose ONLY job is tabPermissions.hidden. + const hideSet = await ql.insert( + 'sys_permission_set', + { + name: 'tab_probe_hide', + label: 'Tab probe — hide showcase', + tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), + }, + { context: SYS }, + ); + const hideSetId = hideSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; + expect(hideSetId, 'hide set stored').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: hideSetId }, { context: SYS }); + + const hidden = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const hiddenNames = (((await hidden.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(hiddenNames, 'tabPermissions.hidden drops the app for this principal').not.toContain('showcase_app'); + + // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — + // most-visible wins across the principal's sets, matching hono's tabRank. + const showSet = await ql.insert( + 'sys_permission_set', + { + name: 'tab_probe_show', + label: 'Tab probe — show showcase', + tab_permissions: JSON.stringify({ showcase_app: 'visible' }), + }, + { context: SYS }, + ); + const showSetId = showSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; + await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: showSetId }, { context: SYS }); + + const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const mergedNames = (((await merged.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(mergedNames, 'most-visible wins: a visible grant overrides the hidden one').toContain('showcase_app'); + + // The probe must not leak into other principals' assertions. + const others = await stack.apiAs(memberTok, 'GET', '/me/apps'); + const otherNames = (((await others.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(otherNames, 'unrelated principals are untouched by the probe sets').toContain('showcase_app'); + }); + // ── #2753: the baseline binds to the everyone anchor at bootstrap ──────── it('bootstrap binds member_default to the everyone position (one channel, D5)', async () => { const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 50d7987985..d1d67cb7a6 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -235,6 +235,7 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill. | Data lifecycle (ADR-0057) | `object.lifecycle` | `storage-growth#adr0057-lifecycle-bounded-growth` | | Webhook materialization | `webhook.object` | `webhook-materialization#webhook-materialization` | | Object semantic roles (ADR-0085) | `object.highlightFields`, `.stageField`, `.fieldGroups` | `semantic-roles#semantic-roles-served` | +| Tab visibility | `permission.tabPermissions` | `me-apps-and-everyone-baseline#me-apps-and-everyone-baseline` | **Registered but unbound.** A proof with no authorable property to ratchet is still registered — otherwise its `@proof:` tag reads as an orphan — and records @@ -246,7 +247,6 @@ still registered — otherwise its `@proof:` tag reads as an orphan — and reco | `permission-set-projection` | a storage invariant (ADR-0094), not an authorable property | | `flow-runas-schedule` | guards `flow.runAs`, already bound to `flow-runas-identity` — one entry carries one `proof` | | `showcase-scope-depth-fallback` | guards `permission.objects.readScope`, already bound — this is the CLI-wiring sibling | -| `me-apps-and-everyone-baseline` | enforces `app.requiredPermissions` / `app.tabPermissions`; `app` is not governed yet | | `showcase-agent-intersection` | a runtime principal-resolution invariant (`onBehalfOf`), not authorable | | `showcase-agent-scope-ceiling` | the OAuth-scope → ceiling-set mapping lives in the runtime resolver | | `showcase-bu-hierarchy-sharing` | stack-level `sharingRules`, not a governed per-type property | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 0ecb5e39cb..a854158bdf 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -135,8 +135,9 @@ "tabPermissions": { "status": "live", "verifiedAt": "2026-07-30", + "proof": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts:1200 (tabRank merges all four values — hidden/default_off/default_on/visible — across resolved sets, most-visible wins; app hidden ⇒ dropped from /me/apps) + packages/runtime/src/security/resolve-execution-context.ts:205 + packages/rest/src/rest-server.ts:1551 (projection)", - "note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Exercised by packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (registered in proof-registry.mts, unbound there because the binding was recorded against the ungoverned `app` type — this governed entry could carry it instead)." + "note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Proof BOUND the same day after the dogfood test was extended to actually AUTHOR the property on a permission set (it had only mentioned tabPermissions in its header): hidden drops the app from /me/apps, a more-visible sibling grant wins it back." }, "rowLevelSecurity": { "children": { diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index c1e564d9c5..29fc0c4628 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -343,7 +343,7 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ }, { id: 'app-tab-permissions', - label: 'GET /me/apps + anchor-bindable baseline', + label: 'Tab visibility (/me/apps) + anchor-bindable baseline', summary: '/me/apps used to read `metadata.list(\'app\')` while stack apps live in the ENGINE REGISTRY, ' + 'returning [] for every principal — leaving `tabPermissions` and `AppSchema.requiredPermissions` ' @@ -352,11 +352,17 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + 'every boot and the baseline flowed only through the fallback channel ADR-0090 D5 rejected.', proofId: 'me-apps-and-everyone-baseline', proofRef: 'packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline', - bound: false, - ledgerBindings: [], - blockedReason: - 'the properties it enforces (`app.requiredPermissions`, `app.tabPermissions`) belong to the ' - + '`app` type, which is not yet governed by the ledger (rollout). Bind when `app` lands.', + // Bound 2026-07-30 (the #3896 security-subset re-verification). The proof + // originally only MENTIONED tabPermissions while exercising the route and + // `app.requiredPermissions` — binding then would have been the + // owner-anchor/allowTransfer mistake (a proof cited for a property it never + // authors). It now authors the property on the PERMISSION-SET side — a set + // whose only content is `tabPermissions: { showcase_app: 'hidden' }` drops + // the app from /me/apps for its holder, and a second, more-visible grant + // wins it back (the tabRank most-visible-wins merge) — which is the + // governed surface (`permission.tabPermissions`; `app` remains ungoverned). + bound: true, + ledgerBindings: [{ type: 'permission', path: 'tabPermissions' }], }, { id: 'agent-delegator-intersection', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index aaebbd38ed..d57140a679 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -123,6 +123,10 @@ describe('registry invariants', () => { 'permission/rowLevelSecurity.check', 'permission/objects.readScope', 'permission/objects.modifyAllRecords', + // Bound 2026-07-30 (#3896 security-subset re-verification) — after the + // me-apps dogfood proof was extended to actually AUTHOR the property on + // a permission set, rather than mentioning it in its header. + 'permission/tabPermissions', ].sort(), ); }); From fcc084e8b3e39889575f9532317d21f35708687d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:05:41 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(dogfood,spec):=20writeScope=20gets=20i?= =?UTF-8?q?ts=20runtime=20proof=20=E2=80=94=20the=20only=20scope=20axis=20?= =?UTF-8?q?without=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three postures asserted on POST-STATE (a system read of the row after each attempt), not just HTTP status: writeScope 'unit' edits a BU co-member's row and does NOT descend into a child BU; an ABSENT writeScope stays owner-only even under a 'unit' readScope, proving the two axes gate independently; and the hierarchy seam fails CLOSED to owner-only without the enterprise resolver. 9/9 against booted stacks. Registered + bound as the scope-depth-write class (ADR-0054): ledger entry carries the proof, README bound table + registry tests updated (89 green). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- ...showcase-scope-depth-write.dogfood.test.ts | 184 ++++++++++++++++++ packages/spec/liveness/README.md | 1 + packages/spec/liveness/permission.json | 5 +- .../spec/scripts/liveness/proof-registry.mts | 16 ++ .../scripts/liveness/proof-registry.test.ts | 4 + 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts new file mode 100644 index 0000000000..6b91c7c954 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0057 D1 — scope-depth WRITE grants on the real showcase app. +// +// `writeScope` widens the owner-set an owner-scoped (`private`) object accepts +// EDITS from, the way `readScope` widens reads — and the two axes are +// consulted INDEPENDENTLY (permission-evaluator.getEffectiveScope reads +// `op.writeScope` for the write class, absent → 'own'; sharing-service.canEdit +// widens its owner-match by `__writeScope`). The sibling proof +// (showcase-scope-depth) covers the read axis; until the 2026-07 security- +// subset re-verification the write axis was `live` on two cited readers but +// carried no runtime proof — this file is that proof. +// +// Three postures, each asserted on POST-STATE (a SYSTEM read of the row after +// the attempt), not just the HTTP status: +// 1. writeScope 'unit' → a BU co-member's note IS editable; a CHILD-BU +// member's note is NOT ('unit' does not descend). +// 2. writeScope absent under readScope 'unit' → the co-member's note is +// READABLE yet NOT editable — the write axis defaults to 'own' and gates +// on its own, independent of how wide reads are. +// 3. the hierarchy seam fails CLOSED: same 'unit' grant, no enterprise +// resolver registered → co-member edit is denied (owner-only), never +// fail-open. +// +// @proof: showcase-scope-depth-write + +import { describe, it, expect, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +const OBJ = '/data/showcase_private_note'; +const WHO = ['alice', 'bob', 'carol'] as const; +type Who = (typeof WHO)[number]; + +function writeProfile(tag: string, writeScope?: 'unit') { + return PermissionSetSchema.parse({ + name: `wscope_${tag}_profile`, + label: `Write-scope ${tag}`, + isDefault: true, + objects: { + showcase_private_note: { + allowRead: true, + allowCreate: true, + allowEdit: true, + readScope: 'unit', + // Posture 2 authors NO writeScope at all — the axis under test must + // come from the schema-default 'own', not from an explicit value. + ...(writeScope ? { writeScope } : {}), + }, + }, + }); +} + +interface World { + stack: VerifyStack; + tokens: Record; + noteIds: Record; + ql: any; +} + +// BU world: alice+carol ∈ bu_parent, bob ∈ bu_child (child of parent). +// Each owns one note. Reference hierarchy resolver as in showcase-scope-depth +// (test fixture for the enterprise seam) — only the 'unit' branch is needed. +async function bootWriteWorld(tag: string, opts: { writeScope?: 'unit'; withResolver?: boolean }): Promise { + const stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, writeProfile(tag, opts.writeScope)], + }), + }); + await stack.signIn(); + const tokens = {} as Record; + for (const who of WHO) tokens[who] = await stack.signUp(`wscope-${who}-${tag}@verify.test`); + + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const SYS = { isSystem: true } as const; + const sys = (o: string, d: any) => ql.insert(o, d, { context: SYS }); + + if (opts.withResolver !== false) { + (stack.kernel as any).registerService('hierarchy-scope-resolver', { + async resolveOwnerIds(c: any, sc: string): Promise { + const meId = String(c.userId ?? ''); + const ids = new Set([meId]); + if (sc !== 'unit') return [...ids]; + const mine = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: SYS }); + const buIds = [...new Set((mine ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))]; + if (!buIds.length) return [...ids]; + const members = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: SYS }); + for (const m of members ?? []) { const u = String(m.user_id ?? ''); if (u) ids.add(u); } + return [...ids]; + }, + }); + } + + const id = {} as Record; + for (const who of WHO) { + id[who] = (await ql.findOne('sys_user', { where: { email: `wscope-${who}-${tag}@verify.test` }, context: SYS }))?.id; + expect(id[who], `${who} resolved`).toBeTruthy(); + } + + let orgId = (await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null))?.id; + if (!orgId) { orgId = `org_w_${tag}`; await sys('sys_organization', { id: orgId, name: 'WScope Org', slug: `wscope_${tag}` }).catch(() => {}); } + + await sys('sys_business_unit', { id: `bu_wparent_${tag}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: `bu_wchild_${tag}`, name: 'Child', kind: 'department', parent_business_unit_id: `bu_wparent_${tag}`, organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: `wm_a_${tag}`, business_unit_id: `bu_wparent_${tag}`, user_id: id.alice }); + await sys('sys_business_unit_member', { id: `wm_c_${tag}`, business_unit_id: `bu_wparent_${tag}`, user_id: id.carol }); + await sys('sys_business_unit_member', { id: `wm_b_${tag}`, business_unit_id: `bu_wchild_${tag}`, user_id: id.bob }); + + const noteIds = {} as Record; + for (const who of WHO) { + const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} ${tag} note` }); + expect(r.status, `${who} creates note`).toBeLessThan(300); + const body: any = await r.json().catch(() => ({})); + noteIds[who] = body?.id ?? body?.record?.id + ?? (await ql.findOne('showcase_private_note', { where: { title: `${who} ${tag} note` }, context: SYS }))?.id; + expect(noteIds[who], `${who} note id resolved`).toBeTruthy(); + } + return { stack, tokens, noteIds, ql }; +} + +/** PATCH as `who`, then report the row's REAL title from a system read. */ +async function tryEdit(w: World, who: Who, targetId: string, newTitle: string): Promise<{ status: number; titleAfter: string }> { + const r = await w.stack.apiAs(w.tokens[who], 'PATCH', `${OBJ}/${targetId}`, { title: newTitle }); + const row = await w.ql.findOne('showcase_private_note', { where: { id: targetId }, context: { isSystem: true } }); + return { status: r.status, titleAfter: String(row?.title ?? '') }; +} + +describe('showcase: scope-depth write — `unit` widens edits to BU co-members (ADR-0057 D1)', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('u', { writeScope: 'unit' }); }, 120_000); + + it("a co-member's note IS editable under writeScope 'unit'", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'edited by alice'); + expect(r.status, 'co-member edit accepted').toBeLessThan(300); + expect(r.titleAfter, 'the write actually landed').toBe('edited by alice'); + }); + + it("a CHILD-BU member's note is NOT editable — 'unit' does not descend", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.bob, 'must not land'); + expect(r.status, 'child-BU edit rejected').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('bob u note'); + }); +}); + +describe('showcase: scope-depth write — absent writeScope stays owner-only even under a wide readScope', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('d', { /* no writeScope */ }); }, 120_000); + + it("the co-member's note is READABLE (readScope 'unit')…", async () => { + const r = await w.stack.apiAs(w.tokens.alice, 'GET', `${OBJ}/${w.noteIds.carol}`); + expect(r.status, 'read widened by readScope').toBe(200); + }); + + it('…yet NOT editable — the write axis defaults to own and gates independently', async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'must not land'); + expect(r.status, 'write denied despite readability').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('carol d note'); + }); + + it('the owner still edits their own note', async () => { + const r = await tryEdit(w, 'alice', w.noteIds.alice, 'alice edits herself'); + expect(r.status).toBeLessThan(300); + expect(r.titleAfter).toBe('alice edits herself'); + }); +}); + +describe('open edition — a hierarchy writeScope fails CLOSED without the enterprise resolver', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('f', { writeScope: 'unit', withResolver: false }); }, 120_000); + + it("writeScope 'unit' degrades to owner-only — a co-member edit is denied, never fail-open", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'must not land'); + expect(r.status, 'unresolvable hierarchy scope = deny').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('carol f note'); + }); +}); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index d1d67cb7a6..168fcb9d21 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -225,6 +225,7 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill. | RLS `check` (post-image) | `permission.rowLevelSecurity.check` | `showcase-d3-d4-capabilities` | | Master-detail controlled-by-parent | `object.sharingModel` | `controlled-by-parent#cbp-controlled-by-parent` | | Scope-depth read grants | `permission.objects.readScope` | `showcase-scope-depth` | +| Scope-depth write grants | `permission.objects.writeScope` | `showcase-scope-depth-write#showcase-scope-depth-write` | | Ownership anchor + bulk writes | `permission.objects.modifyAllRecords` | `owner-anchor-and-bulk-writes` | | Delegation of duty | `position.delegatable` | `delegation-of-duty#delegation-of-duty` | | Static readonly write | `field.readonly` | `showcase-static-readonly#readonly-static-write` | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index a854158bdf..b1da7704dd 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -105,8 +105,9 @@ "writeScope": { "status": "live", "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:224 (getEffectiveScope write branch) + packages/plugins/plugin-sharing/src/sharing-service.ts:230 (canEdit owner-set widened by __writeScope; 'org' short-circuits to unrestricted)", - "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope). Re-verified 2026-07-30: both cited reads confirmed. Sibling of proofed readScope with no dogfood proof of its own — a proof-binding candidate (ADR-0054)." + "proof": "packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts#showcase-scope-depth-write", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:224 (getEffectiveScope write branch; absent → 'own') + packages/plugins/plugin-sharing/src/sharing-service.ts:230 (canEdit owner-set widened by __writeScope; 'org' short-circuits to unrestricted)", + "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope). Re-verified 2026-07-30 and proof-bound the same day: the proof asserts POST-STATE in three postures — 'unit' edits a BU co-member's row and does NOT descend into a child BU; an ABSENT writeScope stays owner-only even under a 'unit' readScope (the axes gate independently); and the hierarchy seam fails CLOSED without the enterprise resolver." } } }, diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 29fc0c4628..1a6416cc7c 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -341,6 +341,22 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + 'wiring rather than an injected bootstrap set — one entry, one `proof` ref, so it cannot also ' + 'bind.', }, + { + id: 'scope-depth-write', + label: 'Scope-depth write grants', + summary: + 'writeScope widens the owner-set an owner-scoped object accepts EDITS from, independently of ' + + "readScope: a co-member row can be readable yet not editable (absent writeScope defaults to 'own' " + + "under a 'unit' read), 'unit' widens to BU co-members without descending into child BUs, and the " + + 'hierarchy seam fails CLOSED to owner-only when the enterprise resolver is absent. Added by the ' + + '2026-07-30 #3896 security-subset re-verification: the entry was live on two confirmed readers ' + + 'but was the only scope axis with no runtime proof — its sibling readScope has carried one since ' + + 'ADR-0054 phase 1.', + proofId: 'showcase-scope-depth-write', + proofRef: 'packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts#showcase-scope-depth-write', + bound: true, + ledgerBindings: [{ type: 'permission', path: 'objects.writeScope' }], + }, { id: 'app-tab-permissions', label: 'Tab visibility (/me/apps) + anchor-bindable baseline', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index d57140a679..fcab4d093b 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -127,6 +127,10 @@ describe('registry invariants', () => { // me-apps dogfood proof was extended to actually AUTHOR the property on // a permission set, rather than mentioning it in its header. 'permission/tabPermissions', + // Bound 2026-07-30, same sweep: writeScope was the only scope axis + // without a runtime proof; its new dogfood file asserts post-state in + // three postures (widen / independent-of-read / fail-closed seam). + 'permission/objects.writeScope', ].sort(), ); }); From 67e9d8b4a9bd1201d6d8b6778962c991675ec87b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:20:00 +0000 Subject: [PATCH 3/4] =?UTF-8?q?refactor(spec)!:=20remove=20the=20RLS-polic?= =?UTF-8?q?y=20`priority`=20key=20=E2=80=94=20conflict=20resolution=20that?= =?UTF-8?q?=20cannot=20exist=20(#3896=20audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-30 security-subset re-verification found priority unread — and, stronger, unreadable-with-effect: applicable policies OR-combine (any match allows access, most permissive wins, per the schema's own describe), so there is never a conflict to order and evaluation order cannot change an outcome. A semantically-void knob on a security policy is worse than dead: an author — very often an AI (ADR-0033) — reads it as a precedence lever and reasons about policy interactions that do not exist. Removed per the #3715/#3950 precedent inside the v17 breaking window: - Tombstoned via retiredKey (#3855): authored priority fails tsc (never) and rejects at parse with the prescription itself; absence parses clean. Both directions pinned in rls.test.ts. - ADR-0087 D2 conversion + D3 chain step (permission-rls-priority-removed): `os migrate meta` deletes the key mechanically — a pure lossless delete. spec-changes.json + protocol upgrade guide regenerated with the entry. - Policy factory helpers, the showcase example permission sets, and content/docs/permissions/rls.mdx no longer author it; the docs table's `enabled` row now states the (since-enforced) contract. - authorable-surface + json-schema baselines regenerated ([RETIRED] recorded); reference docs regenerated (the tombstone prescription shows in the table). - Ledger entry updated to record the removal; tombstone + entry age out ~two majors from now. spec: 6866 tests green (incl. the chain-replay composability gate), tsc clean here and in plugin-security, showcase boots (6/6 me-apps dogfood), liveness gate green, all generated-artifact checks green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .changeset/rls-priority-removed.md | 38 +++++++++++ content/docs/permissions/rls.mdx | 4 +- content/docs/references/security/rls.mdx | 2 +- docs/protocol-upgrade-guide.md | 3 + .../src/security/permission-sets.ts | 3 - .../src/objects/default-permission-sets.ts | 2 +- packages/spec/authorable-surface.json | 4 +- packages/spec/liveness/permission.json | 5 +- packages/spec/spec-changes.json | 12 ++++ packages/spec/src/conversions/registry.ts | 68 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 9 ++- packages/spec/src/security/rls.test.ts | 18 +++-- packages/spec/src/security/rls.zod.ts | 34 ++++++---- 13 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 .changeset/rls-priority-removed.md diff --git a/.changeset/rls-priority-removed.md b/.changeset/rls-priority-removed.md new file mode 100644 index 0000000000..e5e68c8f58 --- /dev/null +++ b/.changeset/rls-priority-removed.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": major +"@objectstack/example-showcase": patch +--- + +refactor(spec)!: remove the RLS-policy `priority` key — it promised conflict resolution that cannot exist (#3896 audit) + +`RowLevelSecurityPolicySchema.priority` was documented as *"Policy priority for +conflict resolution"*. The 2026-07-30 security-subset liveness re-verification +found that **nothing ever read it** — and, stronger, that nothing ever could: +applicable policies **OR-combine** (any match allows access, most permissive +wins — the schema's own describe said so), so there is never a conflict to +order and evaluation order cannot change an outcome. A semantically-void knob +on a security policy is worse than dead: an author — very often an AI +(ADR-0033) — reads it as a precedence lever and reasons about policy +interactions that do not exist. + +Removed per the `tool.requiresConfirmation` (#3715) / `DynamicLoadingConfig` +(#3950) precedent, inside the v17 breaking window: + +- **Tombstoned, not silently stripped** (`retiredKey`, #3855 pattern): an + authored `priority` fails `tsc` (the input type is `never`) and rejects at + parse with the prescription itself — *"policies OR-combine (most permissive + wins), so there is no conflict to order. Delete the key — policy outcomes are + unchanged."* +- **ADR-0087 D2 conversion + D3 chain step** (`permission-rls-priority-removed`): + `os migrate meta` deletes the key from authored sources mechanically — a pure + lossless delete, no semantic residue. spec-changes.json and the protocol + upgrade guide carry the entry. +- The policy factory helpers (`ownerPolicy`, `tenantPolicy`, …), the showcase + example's permission sets, and `content/docs/permissions/rls.mdx` no longer + author it; the docs table's `enabled` row now states the (since-enforced) + contract instead. +- Liveness ledger entry updated to record the removal; the tombstone and entry + age out ~two majors from now. + +Dropping the key changes **no policy outcome anywhere** — that impossibility of +effect is the entire reason for the removal. diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 1659ef4c6b..7173122a70 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -48,7 +48,6 @@ export const ContributorAccess = definePermissionSet({ using: 'assignee == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, ], }); @@ -63,8 +62,7 @@ export const ContributorAccess = definePermissionSet({ | `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) | | `check` | `string` | Predicate rows must satisfy **after a write**. Omit it and `using` is reused | | `positions` | `string[]` | Which positions the policy applies to. Omit = everyone | -| `enabled` | `boolean` | Default `true` | -| `priority` | `number` | Default `0` | +| `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated | At least one of `using` / `check` is required. diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 426381ab5d..ba35b0280b 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -221,7 +221,7 @@ const result = RLSEvaluationResult.parse(data); | **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | ✅ | Whether this policy is active | -| **priority** | `integer` | ✅ | Policy evaluation priority (higher = evaluated first) | +| **priority** | `any` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. | | **tags** | `string[]` | optional | Policy categorization tags | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a90db55e64..de1fd7fa51 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -124,6 +124,8 @@ Finally it removes agent `tools` (#3894): the legacy inline `{type,name,descript Beyond those spec-surface removals, it graduates the seven flow-node config key aliases the executors still tolerated (#3796): the CRUD nodes' `object` (use `objectName`) — the last tenant of the `readAliasedConfig` executor shim, which is deleted with it — plus the six open-coded fallbacks that never went through that shim: notify `to`/`subject`/`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script `functionName`/`input` (use `function`/`inputs`). All are pure key renames with unchanged values and replay losslessly. Like the sharing-rule access level above they keep a load-path acceptance window: none carried a prior deprecation warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema tombstone can reject them — the conversion layer is the only seam that can declare, convert, and retire them. +And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -136,6 +138,7 @@ Beyond those spec-surface removals, it graduates the seven flow-node config key | `flow-node-crud-object-alias` | `flow.node.config.objectName` | CRUD flow-node config key 'object' → 'objectName' (#3796 — `readAliasedConfig` shim graduation) | live — protocol 17 loader accepts the old shape | | `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796) | live — protocol 17 loader accepts the old shape | | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | +| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | --- diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index bfcc2f4706..10dc056613 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -64,7 +64,6 @@ export const ContributorPermissionSet = definePermissionSet({ using: 'assignee == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is // `controlled_by_parent`, a contributor seeing only their own invoices also @@ -80,7 +79,6 @@ export const ContributorPermissionSet = definePermissionSet({ using: 'owner == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read // filter). On UPDATE the new row must still be owned by the caller, so a @@ -97,7 +95,6 @@ export const ContributorPermissionSet = definePermissionSet({ check: 'owner == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, ], }); diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index fd795d6753..8b9800d74b 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -82,7 +82,7 @@ const denyWritesOnManagedObjects = (): Record { + const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity; + if (!Array.isArray(rls)) return ps; + let touched = false; + const next = rls.map((policy, i) => { + if (!isDict(policy) || !('priority' in policy)) return policy; + const { priority: _dropped, ...rest } = policy; + emit({ from: 'priority', to: '(removed)', path: `${path}.rowLevelSecurity[${i}].priority` }); + touched = true; + return rest; + }); + return touched ? { ...ps, rowLevelSecurity: next } : ps; + }); + }, + fixture: { + before: { + permissions: [{ + name: 'contributor', + label: 'Contributor', + rowLevelSecurity: [{ + name: 'own_tasks', + object: 'crm_task', + operation: 'select', + using: 'assignee == current_user.email', + enabled: true, + priority: 10, + }], + }], + }, + after: { + permissions: [{ + name: 'contributor', + label: 'Contributor', + rowLevelSecurity: [{ + name: 'own_tasks', + object: 'crm_task', + operation: 'select', + using: 'assignee == current_user.email', + enabled: true, + }], + }], + }, + expectedNotices: 1, + }, +}; + /** * All conversions, keyed by the protocol major that introduced the canonical * shape. Newest majors last; ordering within a major is application order. @@ -1130,6 +1197,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { const result = RowLevelSecurityPolicySchema.parse(policy); expect(result.name).toBe('tenant_isolation'); expect(result.enabled).toBe(true); // default - expect(result.priority).toBe(0); // default + expect('priority' in result, 'retired key contributes nothing to the parsed output').toBe(false); }); it('should validate a complete policy with all fields', () => { @@ -50,7 +50,6 @@ describe('Row-Level Security (RLS) Protocol', () => { check: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', positions: ['manager', 'director'], enabled: true, - priority: 10, tags: ['team_access', 'hierarchy'], }; @@ -117,16 +116,23 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.enabled).toBe(true); }); - it('should default priority to 0 if not specified', () => { + it('priority is RETIRED: absent parses clean, authored rejects with the prescription', () => { + // Removed by the 2026-07-30 #3896 security audit: policies OR-combine + // (most permissive wins), so the promised "conflict resolution" cannot + // exist and nothing ever read the key. The tombstone keeps the removal + // audible instead of silently stripping an authored value. const policy = { name: 'test_policy', object: 'account', operation: 'select', using: 'owner_id == current_user.id', }; + expect('priority' in RowLevelSecurityPolicySchema.parse(policy)).toBe(false); - const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.priority).toBe(0); + const authored = { ...policy, priority: 10 } as never; + const r = RowLevelSecurityPolicySchema.safeParse(authored); + expect(r.success).toBe(false); + expect(JSON.stringify(!r.success ? r.error.issues : [])).toContain('removed in @objectstack/spec 17.0.0'); }); it('should handle policies for all operations', () => { @@ -457,12 +463,10 @@ describe('Row-Level Security (RLS) Protocol', () => { using: '1 == 1', // Always true - see everything positions: ['ceo', 'cfo', 'cto'], enabled: true, - priority: 100, // Highest priority }; const result = RowLevelSecurityPolicySchema.parse(policy); expect(result.using).toBe('1 == 1'); - expect(result.priority).toBe(100); }); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 3e91183896..daf93c613d 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { retiredKey } from '../shared/retired-key'; /** * # Row-Level Security (RLS) Protocol @@ -382,17 +383,26 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ .describe('Whether this policy is active'), /** - * Policy priority for conflict resolution. - * Higher numbers = higher priority. - * When multiple policies apply, the most permissive wins (OR logic). - * Priority is only used for ordering evaluation (performance). - * - * @default 0 + * REMOVED — `priority` promised "conflict resolution" that cannot exist. + * + * Applicable policies OR-combine (any match allows access — the doc above + * `RLSCompiler.compileFilter` and this schema's own former describe both say + * most-permissive-wins), so there is never a conflict to order and evaluation + * order cannot change an outcome. Nothing ever read the key: the 2026-07-30 + * security-subset liveness re-verification (#3896 follow-up) closed the call + * graph across the collection site, the projection round-trip and the + * compiler, and found no consumer. A semantically-void knob on a SECURITY + * policy is worse than dead — an author (very often an AI, ADR-0033) reads it + * as a precedence lever and reasons about policy interactions that do not + * exist. Removed per the #3715 / #3950 precedent; tombstoned so the removal + * is audible (tsc `never` + the parse-time prescription) instead of a silent + * strip. */ - priority: z.number() - .int() - .default(0) - .describe('Policy evaluation priority (higher = evaluated first)'), + priority: retiredKey( + '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). ' + + 'It never had an effect and could not: applicable policies OR-combine (most permissive wins), ' + + 'so there is no conflict to order. Delete the key — policy outcomes are unchanged.', + ), /** * Tags for policy categorization and reporting. @@ -546,7 +556,6 @@ export const RLS = { operation: 'all', using: `${ownerField} == current_user.id`, enabled: true, - priority: 0, }), /** @@ -566,7 +575,6 @@ export const RLS = { using: `${tenantField} == current_user.organization_id`, check: `${tenantField} == current_user.organization_id`, enabled: true, - priority: 0, }), /** @@ -580,7 +588,6 @@ export const RLS = { using: condition, positions, enabled: true, - priority: 0, }), /** @@ -594,6 +601,5 @@ export const RLS = { using: '1 == 1', // Always true positions, enabled: true, - priority: 0, }), } as const; From 8e288c55013476edd18e0af74db3309e0d8df1da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:46:16 +0000 Subject: [PATCH 4/4] fix(dogfood): the invoice-seed-isolation fixture no longer authors the retired rls priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tombstone did its job on the first authored value it met in CI — this fixture parsed a policy with priority:10 at module top level and got the prescription. Missed by the pre-removal sweep because that grep filtered on rls/policy keywords appearing on the SAME LINE as priority, which a key inside a policy literal does not satisfy — the exact filtered-search false absence the liveness README warns about. The re-sweep that found this one checked every file containing rowLevelSecurity for a priority key; the two other hits are different features (nav contribution ordering, hook priority). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .../dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index 057329e855..9bb80cfbce 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -39,7 +39,6 @@ const demoSet = PermissionSetSchema.parse({ operation: 'select', using: 'owner = current_user.email', enabled: true, - priority: 10, }, ], });