Feature: Roles and permissions content scope: own and all enforcement on articles and entries, Publish as its own permission, typed grants As the owner of a RenewCMS site I want articles and entries to be checked against the caller's content permissions, by owner and by content type So that a Contributor edits only their own work, a Writer cannot publish, and a role can differ per content type Background: Given the spec of record is docs/design/screens/roles.md (catalogue, "Hidden or disabled", "HTTP status to copy", sections 6 and 7) and docs/design/routes.md "Roles and permissions" (created_by, Enforcement) And features/roles_permissions_foundation.feature provides the catalogue and built-in roles, and features/roles_permissions_enforcement.feature provides the 403 contract: status 403, body "missing_permission\n", header X-Missing-Permission, decided from the caller's live role on every request, API keys following their creator And this slice supersedes two clauses of the enforcement feature: owner-only keys are effective only on a Locked role (see "Owner-only keys are effective only on a locked role"), and articles and entries are no longer ungated And "ok" means the request passes the gate and the scope check. A later 400, 404 or 409 from the handler is not a refusal And "403" means status 403, body "missing_permission\n", the header X-Missing-Permission holding the BASE catalogue key (content.update, never content.update:events), no side effect, no audit event, and the use case writes nothing And a refusal by scope is a role.MissingPermissionError returned by the use case (errors.Is role.ErrMissingPermission, errors.As for the key), written by access.WriteIfMissingPermission And the typed key form is content.:. The type key of articles is the built-in key "article". The type key of an entry is the content type key of the URL And a typed grant replaces the wildcard for that type, whether it lowers or raises it. Only the wildcard applies to a type with no typed row And typed keys exist only for the 5 content.* keys. A typed row of scope none is an explicit deny for that type And each action reads its own key, with no implied keys: content.publish does not imply content.update, content.update does not imply content.read, content.read implies nothing And own means the caller's user id equals CreatedBy. All means any. None means no. created_by 0 (unattributed) is owned by nobody, and caller id 0 owns nothing And scope is decided AFTER loading the article or entry, by the use case, from the caller's resolved grants passed in by the handler. Use cases never read user.Role And the handler path for a scoped write is: Authenticator, RequireCurrentToken, RequireVerifiedUser, then a gate that refuses when the typed scope is none (before any lookup), then id/body validation (400), then the use case: load (404), scope check (403) immediately after the load and before any other lookup or validation, then the write And an id that does not exist answers 404 even for a caller whose scope is own, because ownership is unknown And entries have no status. content.publish applies to articles only (PATCH .../publish and .../unpublish). No schedule route exists. A future one must use content.publish And the type key "article" is shared with entry routes. Creating a content type whose key equals a built-in key (article, author, site, media, user) answers 409 apperr.ContentTypeKeyTakenErrorMessage And the users (all verified, valid jwt cookie, token_version 0) are the foundation's user 3 Owner, user 5 Admin, user 9 Writer, plus these on custom, unlocked roles: | user | role | grants | | 21 | Contributor | content.read=all, content.create=all, content.update=own | | 22 | Reader own | content.read=own, content.create=all | | 23 | Editor | content.read=all, content.update=all, content.publish=all, content.delete=own | | 24 | Publisher own | content.read=all, content.publish=own | | 25 | Typed A | content.read=all, content.create=all, content.update=all, content.update:events=own, content.update:article=none | | 26 | Typed B | content.read:events=all, content.create:events=all, content.update:events=own | | 27 | No content | schema.view=all | And the articles are: | id | state | created_by | | 10 | draft | 21 | | 11 | draft | 9 | | 12 | online| 3 | | 13 | draft | 3 | | 14 | draft | 0 | | 15 | online| 21 | | 16 | draft | 22 | | 17 | draft | 24 | And the entries are, in content types "events" and "venues": | id | type | created_by | | 1 | events | 21 | | 2 | events | 9 | | 3 | events | 0 | | 4 | events | 25 | | 5 | venues | 3 | | 6 | events | 22 | | 7 | events | 26 | | 8 | events | 23 | Scenario Outline: GrantSet.ScopeFor resolves wildcard and typed keys Given a role (not locked) with grants When ScopeFor("", "") is evaluated Then the scope is Examples: | grants | key | type | scope | | content.update=all | content.update | | all | | content.update=all | content.update | blog | all | | content.update=all, content.update:blog=own | content.update | blog | own | | content.update=all, content.update:blog=own | content.update | news | all | | content.update=all, content.update:blog=none | content.update | blog | none | | content.update=own, content.update:blog=all | content.update | blog | all | | content.update:blog=all | content.update | blog | all | | content.update:blog=all | content.update | news | none | | content.update:blog=all | content.update | | none | | content.create=all, content.create:blog=none | content.create | blog | none | | content.create=all, content.create:blog=all | content.create | blog | all | | content.read=all | content.read | Bad Key | none | | content.read=all | content.read | (101 characters) | none | | schema.view=all | schema.view | blog | none | | schema.view=all | schema.view | | all | | content.explode=all | content.explode| | none | | (no grants) | content.read | | none | Scenario Outline: One helper decides own and all Given a role holding content.update at When Permits("content.update", "blog", caller=, createdBy=) is evaluated Then the result is Examples: | scope | caller | created_by | allowed | | all | 9 | 21 | true | | all | 9 | 0 | true | | own | 21 | 21 | true | | own | 9 | 21 | false | | own | 21 | 0 | false | | own | 0 | 0 | false | | none | 21 | 21 | false | Scenario: The own and all rule has exactly one implementation Then internal/domain/role holds one exported helper deciding ownership (GrantSet.Permits, built on GrantSet.ScopeFor) And no use case, handler or middleware compares a caller id with CreatedBy, or reads a GrantSet entry, for an authorization decision. They call ScopeFor, Permits, Holds or HoldsAll And the 2a decisions for activity.view, apikeys.view, apikeys.revoke and authors.edit are evaluated through the same helper, with the same results as in the enforcement feature And for authors.edit own means "the author linked to the caller" (existing rule), derived from ScopeFor, not from CreatedBy Scenario: Role.Allows and AllowsFor are removed Then Role no longer has Allows, AllowsFor or scopeOf And a role is evaluated only through Role.GrantSet(), the single constructor of a GrantSet. NewGrantSet is unexported or removed And Role.GrantSet() drops owner-only keys when the role is not Locked, drops unknown keys, drops typed keys on non-content keys or with an invalid type key, and normalises duplicates (see next scenarios) And a toggle key stored at scope own, or any key stored at a scope other than none, own or all, is treated as none Scenario Outline: Duplicate rows resolve to the most restrictive scope Given a stored role with the rows in this order When the GrantSet is built Then ScopeFor("", "") is And the result is the same when the rows are in the opposite order Examples: | rows | key | type | scope | | content.update=own, content.update=all | content.update | | own | | content.update=all, content.update=own | content.update | | own | | content.update:blog=all, content.update:blog=own | content.update | blog | own | | content.update=all, content.update:blog=own | content.update | blog | own | | content.read=all, content.read=none | content.read | | none | Scenario Outline: ValidateGrants rejects duplicates and validates typed keys Given a role to be written with the grants When ValidateGrants(locked=false, grants) is evaluated Then the result is (errors.Is) Examples: | grants | result | | content.update=all, content.update=own | role.ErrDuplicateGrant | | content.update:blog=all, content.update:blog=own| role.ErrDuplicateGrant | | content.update=all, content.update:blog=own | nil | | content.update:blog=none | nil | | content.update=none | role.ErrInvalidScope | | content.create:blog=none | nil | | content.create:blog=own | role.ErrInvalidScope | | content.update:Bad Key=all | role.ErrUnknownPermission | | schema.view:blog=all | role.ErrUnknownPermission | | settings.delete_site=all | role.ErrOwnerOnlyGrant | Scenario: Owner-only keys are effective only on a locked role Given a custom role, not locked, with the hand-placed grant system.apply_updates=all When user 20 sends POST /v1/system/update/check Then the response is 403 missing_permission with X-Missing-Permission "system.apply_updates" And GET /v1/auth/me for that user does not list system.apply_updates And the built-in Owner role, which is Locked, still holds all 34 keys And the same refusal applies to settings.delete_site, roles.edit_owner_only, system.edit_retention and system.transfer_ownership on an unlocked role Scenario: The permission check loads the role once and no grants separately Given a role repository that counts calls and a user repository that counts calls When a gated request is sent with a session cookie Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once And List is called zero times by the permission check and by GetMyAccessUseCase (ListGrants was removed by roles_permissions_roles_api) And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load) And the status codes and the order of checks of the enforcement feature are unchanged And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it Scenario: The router uses role.Key constants Then internal/domain/role/roleKeys.go holds one exported constant per catalogue key (34), named from the key (content.read is KeyContentRead) And api/router.go and api/middleware contain no string literal equal to a catalogue key And a drift test fails when a catalogue key has no constant, or a constant has no catalogue key Scenario Outline: Reading articles shows what the caller may see Given the articles of the Background When sends GET /v1/article/ Then the result is Examples: | caller | id | result | | no cookie | 12 | 200 | | no cookie | 10 | 404 Article not found | | user 12 unverified, Admin role | 10 | 404 Article not found | | user 9 Writer (read all) | 10 | 200 | | user 9 Writer | 14 | 200 | | user 22 (read own) | 16 | 200 | | user 22 (read own) | 12 | 200 | | user 22 (read own) | 10 | 404 Article not found | | user 22 (read own) | 14 | 404 Article not found | | user 27 (no content.read) | 12 | 200 | | user 27 (no content.read) | 10 | 404 Article not found | | user 26 (typed read on events only) | 10 | 404 Article not found | | user 13, role_id 99, unresolvable | 12 | 200 | | user 13, role_id 99, unresolvable | 10 | 404 Article not found | | API key created by user 9 | 10 | 200 | | API key created by user 22 | 10 | 404 Article not found | Scenario: Public article routes never answer 403 Given user 27 holds no content.read When the client sends GET /v1/article, GET /v1/article/12 and GET /v1/article/12/seo with that cookie Then every response is 200 and none is 403 And a role repository failure while resolving the caller's role answers 500 "Internal server error" And a request with no cookie never touches the role repository Scenario Outline: The article list is the visible set Given the articles of the Background When sends GET /v1/article Then the ids returned are And total is and counts are Examples: | caller | query | ids | total | counts | | no cookie | | 12, 15 | 2 | all 2, online 2, draft 0 | | user 27 | | 12, 15 | 2 | all 2, online 2, draft 0 | | user 9 Writer | | 10, 11, 12, 13, 14, 15, 16, 17 | 8 | all 8, online 2, draft 6 | | user 22 (read own) | | 12, 15, 16 | 3 | all 3, online 2, draft 1 | | user 22 (read own) | ?status=draft | 16 | 1 | all 3, online 2, draft 1 | | user 22 (read own) | ?status=online| 12, 15 | 2 | all 3, online 2, draft 1 | | user 21 (read all) | ?status=draft | 10, 11, 13, 14, 16, 17 | 6 | all 8, online 2, draft 6 | And counts.suggestions and a ?seo= filter consider only the visible articles And the ids are ordered by the existing default sort Scenario Outline: Reading an article's SEO follows the same visibility When sends GET /v1/article//seo Then the result is Examples: | caller | id | result | | no cookie | 12 | 200 | | no cookie | 10 | 404 Article not found | | user 22 (read own) | 16 | 200 | | user 22 (read own) | 10 | 404 Article not found | | user 9 Writer | 10 | 200 | Scenario Outline: The SEO check is a read of a visible article When sends POST /v1/article//seo/check with body {"excerpt": "A short summary"} Then the result is Examples: | caller | id | result | | user 27 | 12 | 403 missing_permission, header content.read | | user 22 (read own) | 16 | 200 | | user 22 (read own) | 12 | 200 | | user 22 (read own) | 10 | 404 Article not found | | user 9 Writer | 10 | 200 | | user 9 Writer | 999 | 404 Article not found | Scenario Outline: Creating an article needs content.create for the article type When sends POST /v1/article with body {"title": "A new draft"} Then the result is Examples: | caller | result | | user 21 Contributor | 201, is_online false, stored created_by 21 | | user 22 Reader own | 201, is_online false, stored created_by 22 | | user 9 Writer | 201, is_online false, stored created_by 9 | | user 25 Typed A | 201, update:article=none does not affect create | | user 24 Publisher own | 403 missing_permission, header content.create, nothing stored | | user 26 Typed B | 403 missing_permission, header content.create, nothing stored | | user 27 | 403 missing_permission, header content.create, nothing stored | Scenario Outline: Updating and deleting an article follow the scope of the article Given the articles of the Background When sends Then the result is Examples: | caller | call | result | | user 21 Contributor | PATCH /v1/article/10 {"body": "x"} | 200 | | user 21 Contributor | PATCH /v1/article/11 {"body": "x"} | 403 content.update, article 11 unchanged | | user 21 Contributor | PATCH /v1/article/14 {"body": "x"} | 403 content.update | | user 21 Contributor | PATCH /v1/article/999 {"body": "x"} | 404 Article not found | | user 21 Contributor | PATCH /v1/article/11 {"nothing": 1} | 400 apperr.BodyErrorMessage, as before | | user 21 Contributor | DELETE /v1/article/10 | 403 content.delete, before any lookup (also for id 999) | | user 9 Writer | PATCH /v1/article/13 {"body": "x"} | 200 | | user 9 Writer | DELETE /v1/article/11 | 403 content.delete | | user 23 Editor | PATCH /v1/article/13 {"body": "x"} | 200 | | user 23 Editor | DELETE /v1/article/13 | 403 content.delete (created by user 3) | | user 5 Admin | DELETE /v1/article/13 | 204 | | user 3 Owner | PATCH /v1/article/10 {"body": "x"} | 200 | | user 24 Publisher own | PATCH /v1/article/17 {"body": "x"} | 403 content.update (publish does not imply update), before any lookup | | user 27 | PATCH /v1/article/12 {"body": "x"} | 403 content.update | And a refused call changes no field, records no audit event and creates no author or site side effect, including when the body also changes site_id, author_ids or the slug And an allowed own update keeps created_by unchanged Scenario: An own-scope user can delete only their own article Given user 23 is the Editor with content.delete=own and article 18 (draft) was created by user 23 When user 23 sends DELETE /v1/article/18 and then DELETE /v1/article/12 Then the first answers 204 and the article is gone and one article.delete audit event is recorded And the second answers 403 missing_permission with X-Missing-Permission "content.delete" and article 12 is still stored Scenario Outline: Publish is its own permission Given the articles of the Background When sends Then the result is Examples: | caller | call | result | | user 9 Writer | PATCH /v1/article/11/publish | 403 content.publish, article 11 is still a draft | | user 9 Writer | PATCH /v1/article/12/unpublish | 403 content.publish, article 12 is still online | | user 21 Contributor | PATCH /v1/article/10/publish | 403 content.publish (own article, but publish is none) | | user 23 Editor | PATCH /v1/article/11/publish | 200, is_online true, one article.publish audit event | | user 23 Editor | PATCH /v1/article/12/unpublish | 200, is_online false, one article.unpublish audit event | | user 24 Publisher own | PATCH /v1/article/17/publish | 200, is_online true | | user 24 Publisher own | PATCH /v1/article/17/unpublish | 200, is_online false | | user 24 Publisher own | PATCH /v1/article/10/publish | 403 content.publish, article 10 unchanged | | user 24 Publisher own | PATCH /v1/article/999/publish | 404 Article not found | | user 5 Admin | PATCH /v1/article/10/publish | 200 | | user 27 | PATCH /v1/article/12/publish | 403 content.publish | Scenario: Saving a draft without the publish permission Given user 9 (Writer: content.update=all, no content.publish) and draft article 11 When user 9 sends PATCH /v1/article/11 with {"body": "new text"} Then the status is 200, the body holds the new text and is_online false And the article is still a draft And the server answers no copy for this: "Ask an editor to publish." is shown by the SPA, never produced by the API Scenario: Update never changes the status Given user 9 and draft article 11 When user 9 sends PATCH /v1/article/11 with {"body": "x", "is_online": true} Then the status is 200 and the article is still a draft And user 9 sends PATCH /v1/article/12 with {"body": "x"} on the online article and it stays online Scenario: A new article is always a draft whatever the role Given user 3 (Owner) sends POST /v1/article with {"title": "Owner draft", "is_online": true} Then the status is 201 and is_online is false Scenario: A refused publish leaves no trace Given user 9 sends PATCH /v1/article/11/publish Then the response is 403 missing_permission and no audit event is recorded and the article's updated_at is unchanged Scenario Outline: Entries follow the read scope When sends Then the result is Examples: | caller | call | result | | user 21 Contributor | GET /v1/content-type/events/entry | 200, entries 1, 2, 3, 4, 6, 7, 8, total 7 | | user 22 (read own) | GET /v1/content-type/events/entry | 200, entry 6 only, total 1 | | user 22 (read own) | GET /v1/content-type/events/entry?page=1&size=5 | 200, entry 6, total 1, page 1, size 5 | | user 22 (read own) | GET /v1/content-type/events/entry/6 | 200 | | user 22 (read own) | GET /v1/content-type/events/entry/1 | 404 entry_not_found | | user 22 (read own) | GET /v1/content-type/events/entry/3 | 404 entry_not_found (created_by 0) | | user 27 | GET /v1/content-type/events/entry | 403 content.read, before any lookup | | user 27 | GET /v1/content-type/events/entry/1 | 403 content.read | | user 27 | GET /v1/content-type/nope/entry | 403 content.read | | user 21 Contributor | GET /v1/content-type/nope/entry | 404 content_type_not_found | | user 5 Admin | GET /v1/content-type/events/entry/2 | 200 | And under scope own, a ?q= search, total, page and size are computed over the caller's own entries only Scenario Outline: Entries follow the create, update and delete scope When sends Then the result is Examples: | caller | call | result | | user 21 Contributor | POST /v1/content-type/events/entry {"data": {...}} | 201, created_by 21 | | user 27 | POST /v1/content-type/events/entry {"data": {...}} | 403 content.create, nothing stored | | user 21 Contributor | PUT /v1/content-type/events/entry/1 {"data": {...}} | 200, created_by stays 21, updated_by 21 | | user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update, entry 2 unchanged | | user 21 Contributor | PUT /v1/content-type/events/entry/3 {"data": {...}} | 403 content.update | | user 21 Contributor | PUT /v1/content-type/events/entry/999 {"data": {...}} | 404 entry_not_found | | user 21 Contributor | PUT /v1/content-type/nope/entry/1 {"data": {...}} | 404 content_type_not_found | | user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {"title": ""}} | 403 content.update (scope is checked before field validation) | | user 21 Contributor | DELETE /v1/content-type/events/entry/1 | 403 content.delete, before any lookup, entry 1 stored | | user 9 Writer | PUT /v1/content-type/events/entry/2 {"data": {...}} | 200 | | user 9 Writer | DELETE /v1/content-type/events/entry/2 | 403 content.delete | | user 23 Editor | DELETE /v1/content-type/events/entry/8 | 204 | | user 23 Editor | DELETE /v1/content-type/events/entry/2 | 403 content.delete, entry 2 stored | | user 5 Admin | DELETE /v1/content-type/events/entry/2 | 204 | | user 3 Owner | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 | And no entry route requires content.publish Scenario Outline: Typed grants replace the wildcard for their type When sends Then the result is Examples: | caller | call | result | | user 25 Typed A | PATCH /v1/article/10 {"body": "x"} | 403 content.update (update:article=none replaces update=all), before any lookup | | user 25 Typed A | PUT /v1/content-type/events/entry/4 {"data": {...}} | 200 (update:events=own, entry created by 25) | | user 25 Typed A | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update (update:events=own replaces update=all) | | user 25 Typed A | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 (wildcard update=all applies to venues) | | user 26 Typed B | GET /v1/content-type/events/entry | 200 | | user 26 Typed B | GET /v1/content-type/venues/entry | 403 content.read | | user 26 Typed B | POST /v1/content-type/events/entry {"data": {...}} | 201 | | user 26 Typed B | POST /v1/content-type/venues/entry {"data": {...}} | 403 content.create | | user 26 Typed B | PUT /v1/content-type/events/entry/7 {"data": {...}} | 200 | | user 26 Typed B | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update | | user 26 Typed B | GET /v1/article/10 | 404 Article not found (no article read grant: public view) | | user 26 Typed B | GET /v1/article/12 | 200 | | user 26 Typed B | POST /v1/article {"title": "A new draft"} | 403 content.create | And the header is always the base key, never "content.update:events" Scenario: An unknown type key falls under the wildcard or is refused Given user 26 (typed grants for events only) When user 26 sends GET /v1/content-type/venues/entry and GET /v1/content-type/nope/entry Then both answer 403 content.read, with no lookup of the content type And user 21, whose wildcard grants apply, gets 404 content_type_not_found on GET /v1/content-type/nope/entry Scenario: Creating a content type with a built-in key is refused Given user 5 (Admin) When user 5 sends POST /v1/content-type with a valid body whose key is "article" Then the status is 409 and the body is apperr.ContentTypeKeyTakenErrorMessage and nothing is stored And the same holds for the keys "author", "site", "media" and "user" And content types that already exist are not touched Scenario: Admin and Owner pass every content check Given the Admin and the Owner Then they read, create, update, delete and publish every article and entry whatever its created_by And they list every article including drafts, with counts all 8, online 2, draft 6 Scenario: Writer is effective on content Given user 9 on the Writer role Then Writer reads and updates every article and entry, and creates articles and entries And Writer gets 403 content.delete on DELETE of an article or an entry, and 403 content.publish on publish and unpublish And editing an online article keeps it online Scenario: A session follows the role live for content Given user 21 gets 403 on PATCH /v1/article/11 {"body": "x"} When the Contributor role's grant becomes content.update=all in the repository Then the next PATCH /v1/article/11 with the same cookie answers 200, with no re-login And when the grant is then removed, the next PATCH answers 403 content.update Scenario: An API key follows its creator's role and owns what its creator owns Given a valid API key created by user 21 When the key sends POST /v1/article {"title": "Key draft"} Then the stored article has created_by 21 And the key's PATCH on that article answers 200 and on article 11 answers 403 content.update And the key's PATCH /v1/article/11/publish answers 403 content.publish And when user 21's role_id is changed to the Editor role, the same key's PATCH /v1/article/11/publish answers 200 with no new key Scenario: A user whose role cannot be resolved is refused on gated content routes Given user 13 has role_id 99 When user 13 sends POST /v1/article, PATCH /v1/article/10 and GET /v1/content-type/events/entry Then every response is 403 missing_permission And a role repository failure answers 500 "Internal server error" Scenario: Verification and token checks still come first Given user 12 is not verified and holds the Admin role When user 12 sends PATCH /v1/article/10 with {"body": "x"} Then the status is 403 apperr.VerificationRequiredErrorMessage And a stale token_version gives 401 and no cookie gives 401 on every gated content route Scenario Outline: Media routes keep their scope behaviour When sends Then the result is Examples: | caller | call | result | | user 9 Writer | DELETE /v1/media/1 | 403 media.delete | | role with media.delete=own | DELETE /v1/media/1 | 403 media.delete (images record no creator, own fails closed) | | role with media.delete=own | POST /v1/media/merge | 403 media.delete | | role with media.delete=own | POST /v1/media/purge | 403 media.delete | | role with media.delete=all | DELETE /v1/media/1 | ok | | user 9 Writer | GET /v1/media/1/usage | ok | | user 9 Writer | POST /v1/media | ok | And GET /v1/media/{id}/usage keeps listing every referencing article and entry to any media.view holder, whatever the content scopes (not filtered in this slice) Scenario: authors.edit keeps its 2a behaviour on the shared helper Given the authors.edit Outline of the enforcement feature Then every row gives the same result as before, now evaluated through GrantSet.ScopeFor Scenario: /auth/me lists the effective grants Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all When the client sends GET /v1/auth/me Then access.permissions holds content.update at own and no settings.delete_site And typed grants are not listed in this slice (superseded by roles_permissions_roles_api: typed entries, scope none included, are listed) Scenario: Out of scope here Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route And typed grants are written by slice 3 and exposed to the SPA by a later slice (done by roles_permissions_roles_api: the API writes them and /auth/me lists them) And rows of deleted content types in typed grants are cleaned up by slice 3 (done by roles_permissions_roles_api)