MOTIR-2261Done
Built-in roles as permission SETS + `hasPermission` — re-express every predicate in `lib/projects/access.ts`, proven behaviour-neutral by a truth table
Put the indirection in. Define the three built-in roles as SETS over the catalog, add the resolution that turns { accessLevel, workspaceRole, projectRole } into an actor's effective permission set, and rewrite each of the eleven predicates in lib/projects/access.ts as a one-line lookup — with the answers unchanged for every input, proven by an exhaustive truth table rather than asserted.
The shape
lib/permissions/builtinRoles.ts—BUILTIN_ROLE_PERMISSIONS: Record<ProjectRole, ReadonlySet<PermissionKey>>foradmin/member/viewer, typed against the catalog so a key that does not exist fails to compile.ProjectRolekeeps its current home inlib/projects/roles.ts; this card does not move or rename it.resolvePermissions(inputs): ReadonlySet<PermissionKey>inlib/permissions/resolve.ts— the whole policy, expressed once, with both shipped rails INSIDE it rather than around it:- a workspace manager (
isWorkspaceManager) resolves to the full catalog; - a null
workspaceRoleresolves to the empty set — except on apublicproject, which grantsproject:browseplus the threepublic_request:*keys to any actor including an anonymous one, exactly the shipped Story 6.12 grant; - otherwise the actor's project role supplies the base set, and the ACCESS LEVEL subtracts from it: on
limitedandprivatea non-member holds view-and-comment but notwork_item:edit, matching the level tablecanEditandcanCommentimplement today.
- a workspace manager (
hasPermission(inputs, key)— the membership test the predicates call.- The eleven predicates keep their names, signatures and exports and become
hasPermission(i, '<key>').canCreateAttachmentsandcanDeleteAllAttachments, which today are re-exported aliases ofcanComment/canModerateComments, become real lookups on their OWN keys — the aliasing exists only because the two shared a table, and the catalog is what lets them diverge later without a call-site change. - The header comment in
lib/projects/access.tsis rewritten, not left describing a decision table the file no longer contains. The same for the sentence inlib/mcp/scopes.tsthat refers to "the 6.4 role model" — one clause, so it names the catalog.
Acceptance criteria
resolvePermissionsandhasPermissionexist, are pure (no Prisma import, no IO), and are typed overPermissionKey.- All eleven exported predicates in
lib/projects/access.ts—canBrowse,canEdit,canComment,canModerateComments,canCreateAttachments,canDeleteAllAttachments,canManageWatchers,canManageProject,canSubmitToTriage,canUpvotePublicRequest,canCommentPublicRequest— keep their exported names and signatures and each resolve throughhasPermission. - The parity truth table: a Vitest table drives every combination of
accessLevel(4) ×workspaceRole(owner / admin / member / null) ×projectRole(admin / member / viewer / null) — 64 rows — through all eleven predicates, and asserts each result against the expected value transcribed from the pre-change policy. The expectations are written as literal booleans in the test, not computed from the new code. - The anonymous public actor case is covered explicitly:
workspaceRoleandprojectRoleboth null on apublicproject grants exactlyproject:browseand the threepublic_request:*keys, and nothing else. - No
assertCan*call site anywhere inliborappis edited by this card —git diff --stattouches onlylib/permissions/*,lib/projects/access.ts, the scope-module comment, and tests. - Per-file coverage on every new and changed module meets the ≥90% floor.
Context refs
lib/projects/access.ts— the eleven predicates and the decision tables being re-expressed; its header records the access-level semantics the resolution must reproduce.lib/projects/roles.ts—PROJECT_ASSIGNABLE_ROLES,ProjectRole,isWorkspaceManager,PROJECT_ACCESS_LEVELS.lib/mcp/scopes.ts— the one comment clause naming the role model, corrected here.docs/decisions/public-projects.md— the Story 6.12 grant the anonymous-actor branch must preserve.- The catalog card — the keys this card resolves over.