Resolves the authorization findings ACC-1 and ACC-2 from the full review.
ACC-1 (creator permanently retains membership-management authority) turns
out to be unfixable at the policy boundary on pinned jazz-tools alpha.53:
the intended bootstrap window ("creator authority only while no admin
membership exists") requires a negated existence condition, and the
alpha.53 policy engine SILENTLY DROPS Not around Exists/ExistsRel —
Not(ExistsRel(...)) evaluates as if un-negated. A LEFT-join IsNull
anti-join is not null-extended in policy evaluation either. Probe results
are recorded in docs/decisions/group-creator-authority-alpha53.md.
Consequently:
- Creator authority becomes an explicit, documented trust property (a
permanent superseat), stated in policies.ts, permissions.md, the group
example, and the access reference, so products do not present creator
handover as complete.
- ACC-2: groups.allowDelete gains the $createdBy branch, which grants no
net-new authority (a creator can already self-bootstrap admin and delete
transitively) but authorizes createGroup's compensating delete — a failed
first-admin insert no longer strands an undeletable orphan group.
- An engine canary test asserts the dropped-negation behavior; it fails
the moment a jazz-tools upgrade fixes Not, prompting the bootstrap-window
design recorded in the decision record.
- New harness tests codify the permanent-superseat contract (exclusive to
the creator, survives demotion/removal) and creator-only orphan deletion.- commit
- 58ba190b352ddb2dc88d5f62516beba9b4125679
- parent
- 810e6fb17af9b999096e84fb8ffd999cd956968b
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 70 | ++++++++++++++ |
| M | docs/ | 5 | +- |
| M | docs/ | 10 | ++ |
| M | docs/ | 11 | ++- |
| M | package/ | 150 | ++++++++++++++++++++++++++++++ |
| M | package/ | 3 | + |
| M | package/ | 20 | ++++- |
@@ -0,0 +1,70 @@
1+# Group-creator authority under Jazz alpha.53 policy negation limits2+3+Date: 2026-07-17\4+Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\5+Decision: document permanent creator authority; grant direct creator delete; revisit on upgrade6+7+## Problem8+9+The `groupAccess` template gives a group's creator `$createdBy` update authority on the group row so10+they can insert the first admin membership (membership management derives from group-update via11+`allowedTo.update("groupId")`). That authority never lapses: a creator who is later demoted or12+removed by other admins can restore their own admin membership at any time. Separately, a failed13+first-admin insert used to strand an undeletable orphan group, because rollback deletion required14+the admin membership that had just failed to insert.15+16+The intended fix was a **bootstrap window**: creator authority over the group row holds only while17+the group has no admin membership. That requires a negated existence condition — "no admin18+membership row exists for this group".19+20+## Exact alpha.53 engine behavior21+22+The policy IR (`schema.js` `PolicyExpr`) declares `{ type: "Not", expr }`, the permissions compiler23+passes raw IR through, and `schema-permissions.js` serializes `Not` for the wasm core. The core,24+however, **silently drops `Not` around existence conditions**. Probed against `createPolicyTestApp`25+with a two-table group/members app, rule under test on `allowDelete`:26+27+| Probe | Expected | alpha.53 result |28+| ------------------------------------------------- | ---------- | ------------------------------- |29+| `Not(True)` / `Not(False)` | deny/allow | correct |30+| `Not(Cmp column = literal)` | deny | correct |31+| `Exists(...)` / `ExistsRel(...)` (pos + neg ctrl) | — | correct |32+| `Not(Exists(...))`, no matching rows | allow | **denied** |33+| `Not(ExistsRel(...))`, matching row present | deny | **allowed** |34+| `And(True, Not(ExistsRel(...)))`, row present | deny | **allowed** |35+| LEFT-join + `IsNull` anti-join via `ExistsRel` | allow | denied (join not null-extended) |36+37+`Not` over `Exists`/`ExistsRel` evaluates as if the `Not` were absent — a silent wrong-direction38+result, not an error. The `RelExpr` LEFT-join anti-join alternative does not produce null-extended39+rows in policy evaluation, so absence-of-rows is not expressible at all in this engine.40+41+## Decision42+43+- **Creator authority over the group row stays permanent and becomes a documented trust property**44+ of the template: the creator can always update the group and therefore always restore their own45+ admin membership. Products that need creator-proof handover cannot get it from this template on46+ this engine.47+- **The creator additionally receives direct `allowDelete` on their own group rows.** This grants no48+ new authority — a creator can already self-bootstrap an admin membership and delete transitively —49+ and it lets `createGroup` roll back cleanly when the first-admin insert fails, removing the50+ orphan-group failure mode.51+- **An engine canary test** (`access_security_test.ts`, "engine canary") asserts the broken52+ behavior: a `Not(ExistsRel(...))` delete guard that a correct engine would deny. When a jazz-tools53+ upgrade fixes negation, the canary fails, signalling that the bootstrap-window design in this54+ record should replace permanent creator authority:55+ `allowUpdate/allowDelete = anyOf([isAdmin, allOf([$createdBy, Not(Exists(admin membership))])])`,56+ which also preserves creator recovery of admin-less groups.57+58+## Trust and fallback59+60+- The template's authority model is: fixed roles for members, plus a permanent creator superseat.61+ The sharing UI and docs state this; it must not be presented as revocable admin membership.62+- Upstream: the dropped-negation behavior is a jazz-tools core issue worth reporting; until then, no63+ lofi policy may rely on `Not` around `Exists`/`ExistsRel`.64+65+## Evidence sources66+67+- Probe suite output recorded in this record's table (2026-07-17, Deno harness68+ `createPolicyTestApp`, pinned npm cache for `jazz-tools@2.0.0-alpha.53`).69+- `dist/permissions/index.js` (raw IR pass-through), `dist/schema-permissions.js` (`Not`70+ serialization), `dist/ir.d.ts` (`PolicyExprV2.Not`, `RelExpr`).@@ -41,4 +41,7 @@ await groups.removeMember(group.id, recipientIdentity);
4141 4242 Readers can read. Contributors can create and edit their own rows. Writers can edit any group row.4343 Admins can do everything writers can and manage membership. `leaveGroup` removes the current44-principal's membership. Managed sync is required for every group operation.44+principal's membership. Managed sync is required for every group operation. The group's creator45+additionally keeps a permanent superseat: demoting or removing them does not end their ability to46+manage the group — see [Permission templates](../permissions.md#fixed-group-roles) before building47+handover flows.@@ -109,6 +109,16 @@ The helper stores the role label plus fixed capability bits because the pinned J
109109 role-string comparisons inside relationship policies. Only the four role names are accepted by the110110 typed operations; custom roles belong in a raw Jazz policy.111111 112+**The group creator holds a permanent superseat.** Whoever created the group row can always update113+it and — because membership management derives from group-update authority — can always restore114+their own admin membership, even after other admins demote or remove them. This is a documented115+property of the template, not revocable admin membership: the pinned Jazz alpha cannot express116+"creator authority only until the first admin exists" (its policy engine drops negated existence117+conditions), so do not present creator handover as complete in product UI. The creator can also118+delete their own group rows, which is what lets group creation roll back cleanly if bootstrapping119+the first admin membership fails. See120+[the decision record](decisions/group-creator-authority-alpha53.md).121+112122 Direct shares and group membership use a non-secret `lofi1:<app-id>:<jazz-user-id>` sharing113123 identity. It is safe to copy for this purpose but is not a directory, invitation, or sign-in token.114124 The package deliberately does not discover users or deliver invitations.@@ -36,9 +36,14 @@ Collaboration operations reject local-only use with `AccessError.code === "sync-
3636 In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the3737 same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup`3838 therefore serializes the creator-owned group write and first-admin membership write, and attempts a39-compensating group delete if the membership is rejected. This is secure and leaves no usable40-unadministered group in the tested path, but it is not database-transaction atomic. Do not describe41-it as atomic until Jazz can validate that relationship against staged transaction rows.39+compensating group delete if the membership is rejected. The compensating delete is authorized by40+the creator's direct delete authority on their own group rows, so a failed bootstrap does not strand41+an orphan group. This is secure, but it is not database-transaction atomic. Do not describe it as42+atomic until Jazz can validate that relationship against staged transaction rows.43+44+The group creator additionally holds a permanent superseat — see45+[Permission templates](../permissions.md#fixed-group-roles) and46+[the decision record](../decisions/group-creator-authority-alpha53.md).4247 4348 The templates compile ordinary Jazz policies and accept a final raw-policy callback. They do not4449 replace `s.table`, `s.defineApp`, or raw `s.definePermissions`; use those directly when the fixed@@ -281,6 +281,156 @@ Deno.test("official Jazz harness enforces fixed group roles and membership lifec
281281 }282282 });283283 284+Deno.test("group-creator authority is permanent, exclusive, and survives demotion", async () => {285+ const testApp = await createPolicyTestApp(app, permissions, expectLike);286+ try {287+ const creator = testApp.as(session("creator"));288+ const successor = testApp.as(session("successor"));289+ const stranger = testApp.as(session("stranger"));290+291+ const group = await creator.insert(app.workspaces, { name: "handover" }).wait({292+ tier: "global",293+ });294+ // Creator authority never extends to anyone else.295+ stranger.expectDenied((db) => db.update(app.workspaces, group.id, { name: "hijacked" }));296+ stranger.expectDenied((db) =>297+ db.insert(app.workspaceMembers, {298+ groupId: group.id,299+ user_id: "stranger",300+ ...groupRoleCapabilities("admin"),301+ })302+ );303+ stranger.expectDenied((db) => db.delete(app.workspaces, group.id));304+305+ const creatorMembership = await creator.insert(app.workspaceMembers, {306+ groupId: group.id,307+ user_id: "creator",308+ ...groupRoleCapabilities("admin"),309+ }).wait({ tier: "global" });310+ await creator.insert(app.workspaceMembers, {311+ groupId: group.id,312+ user_id: "successor",313+ ...groupRoleCapabilities("admin"),314+ }).wait({ tier: "global" });315+316+ // The documented trust property of the template on the pinned Jazz317+ // alpha.53 engine: demoting or removing the creator does NOT end their318+ // authority — they retain group update and, through it, membership319+ // management, so they can always restore their own admin membership.320+ // The engine cannot express "creator authority only while no admin321+ // exists" (see the negation canary below); when that becomes possible,322+ // this contract is expected to change to a bootstrap-only window.323+ successor.expectAllowed((db) =>324+ db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("reader"))325+ );326+ creator.expectAllowed((db) =>327+ db.update(app.workspaceMembers, creatorMembership.id, groupRoleCapabilities("admin"))328+ );329+ successor.expectAllowed((db) => db.delete(app.workspaceMembers, creatorMembership.id));330+ creator.expectAllowed((db) =>331+ db.insert(app.workspaceMembers, {332+ groupId: group.id,333+ user_id: "creator",334+ ...groupRoleCapabilities("admin"),335+ })336+ );337+ } finally {338+ await testApp.shutdown();339+ }340+});341+342+Deno.test("engine canary: pinned alpha.53 silently drops Not around existence conditions", async () => {343+ // Guard rule that SHOULD deny deletion while an admin membership exists:344+ // Not(ExistsRel(members WHERE groupId = <this group> AND can_manage)).345+ // The pinned engine ignores the negation, so the delete is ALLOWED below.346+ // WHEN THIS TEST FAILS after a jazz-tools upgrade: negation started347+ // working — replace permanent group-creator authority with the348+ // bootstrap-only window design in349+ // docs/decisions/group-creator-authority-alpha53.md and update the350+ // permanent-authority contract test above.351+ const canaryPermissions = s.definePermissions(app, (rawContext) => {352+ const { policy } = rawContext as unknown as {353+ policy: Record<354+ string,355+ {356+ allowRead: { always(): unknown };357+ allowInsert: { always(): unknown };358+ allowDelete: { where(input: unknown): unknown };359+ }360+ >;361+ };362+ policy.workspaces.allowRead.always();363+ policy.workspaces.allowInsert.always();364+ policy.workspaceMembers.allowRead.always();365+ policy.workspaceMembers.allowInsert.always();366+ policy.workspaces.allowDelete.where({367+ type: "Not",368+ expr: {369+ type: "ExistsRel",370+ rel: {371+ Filter: {372+ input: { TableScan: { table: "workspaceMembers" } },373+ predicate: {374+ And: [375+ {376+ Cmp: {377+ left: { column: "groupId" },378+ op: "Eq",379+ right: { OuterColumn: { column: "id" } },380+ },381+ },382+ {383+ Cmp: {384+ left: { column: "can_manage" },385+ op: "Eq",386+ right: { Literal: true },387+ },388+ },389+ ],390+ },391+ },392+ },393+ },394+ });395+ });396+ const testApp = await createPolicyTestApp(app, canaryPermissions, expectLike);397+ try {398+ const creator = testApp.as(session("creator"));399+ const group = await creator.insert(app.workspaces, { name: "canary" }).wait({400+ tier: "global",401+ });402+ await creator.insert(app.workspaceMembers, {403+ groupId: group.id,404+ user_id: "creator",405+ ...groupRoleCapabilities("admin"),406+ }).wait({ tier: "global" });407+ // A working Not would DENY this delete (an admin membership exists).408+ creator.expectAllowed((db) => db.delete(app.workspaces, group.id));409+ } finally {410+ await testApp.shutdown();411+ }412+});413+414+Deno.test("a group without its first admin remains deletable by its creator only", async () => {415+ const testApp = await createPolicyTestApp(app, permissions, expectLike);416+ try {417+ const creator = testApp.as(session("creator"));418+ const stranger = testApp.as(session("stranger"));419+ // A group whose first-admin insert failed: the row exists, no membership.420+ const orphan = await creator.insert(app.workspaces, { name: "orphan" }).wait({421+ tier: "global",422+ });423+ stranger.expectDenied((db) => db.delete(app.workspaces, orphan.id));424+ await creator.delete(app.workspaces, orphan.id).wait({ tier: "global" });425+ assert(426+ (await creator.all(app.workspaces.where({ id: orphan.id }))).length === 0,427+ "the creator's rollback delete did not remove the orphaned group",428+ );429+ } finally {430+ await testApp.shutdown();431+ }432+});433+284434 Deno.test("access templates reject malformed relationship tables with actionable errors", () => {285435 const malformed = s.defineApp({286436 docs: s.table({ title: s.string() }),@@ -174,6 +174,9 @@ export function createGroupOperations<
174174 } as MemberInit));175175 return { group, membership };176176 } catch (cause) {177+ // The creator's direct delete authority on their own group row178+ // authorizes this rollback, so a failed first-admin insert no longer179+ // strands an undeletable orphan group.177180 await settle(db.delete(config.groups, group.id)).catch(() => undefined);178181 throw cause;179182 }@@ -195,6 +195,22 @@ export function defineAccessPolicies<TApp extends object>(
195195 const hasCapability = (groupId: unknown, capability: string) =>196196 members.exists.where({ groupId, user_id: session.user_id, [capability]: true });197197 const isAdmin = (groupId: unknown) => hasCapability(groupId, "can_manage");198+ // Group-creator authority is PERMANENT by design: the creator can always199+ // update the group row, and — because membership management derives from200+ // group-update via `allowedTo.update` — can always restore their own201+ // admin membership after demotion or removal. This is a documented trust202+ // property of the template, not an oversight: scoping creator authority203+ // to a bootstrap window ("only while no admin membership exists")204+ // requires a negated existence condition, and the pinned Jazz alpha.53205+ // policy engine silently drops Not around Exists/ExistsRel — the window206+ // cannot be enforced at the policy boundary. The engine canary in207+ // access_security_test.ts fails when an upgrade fixes negation; implement208+ // the window then. See docs/decisions/group-creator-authority-alpha53.md.209+ //210+ // Direct creator delete below grants no additional authority (a creator211+ // can always self-bootstrap an admin membership and delete transitively);212+ // it exists so group creation can roll back when the first admin insert213+ // fails, instead of stranding an undeletable orphan row.198214 groups.allowRead.where((group: Record<string, unknown>) =>199215 anyOf([isMember(group.id), { $createdBy: session.user_id }])200216 );@@ -202,7 +218,9 @@ export function defineAccessPolicies<TApp extends object>(
202218 groups.allowUpdate.where((group: Record<string, unknown>) =>203219 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])204220 );205- groups.allowDelete.where((group: Record<string, unknown>) => isAdmin(group.id));221+ groups.allowDelete.where((group: Record<string, unknown>) =>222+ anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])223+ );206224 members.allowRead.where(207225 anyOf([208226 { user_id: session.user_id },