You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A granted role can currently be revoked only by the original granter (same signing key — #112's samePublisherClause, AuthorityResolver.java:819). So an admin can't revoke a member another admin admitted, and a member can't leave on their own.
Model: assign/revoke as authorization-scoped latest-wins
State of an (agent, role, spaceRef) key = the sign of the latest authorized assertion by dct:created (subject-IRI tiebreak), active-by-default. This is exactly what preset assignments already do (#302): authorization-scoped latest-wins, notnpx:invalidates (AuthorityResolver.java:1064-1068, 1173-1198).
Only new primitive: key-scoped negative assertions (see Revocation vocabulary below) — mirror gen:DeactivatedPresetAssignment (GEN.java:130; active-by-default, a revocation is just a newer authorized row) — one for instantiations (agent, role, spaceRef) and one for attachments (role, spaceRef), both carrying dct:created.
Re-assignment needs no new vocab (a newer positive assertion); toggling falls out.
Distinct from npx:invalidates, which stays for nanopub-level undo ("this exact statement was a mistake"); the negative assignment is key-level ("agent no longer holds this role, however granted").
Authorization filter — who may publish a winning assertion: whoever's tier could grant the role, plus the assignee for their own role.
Role
May set latest assertion
root admin
nobody at runtime — constitutional (see below)
admin
admins
maintainer
admins
member
admins, maintainers
observer
admins, maintainers, members, + self
Self may always revoke its own role (leave) at any tier, but may only assign at observer (self-attest).
Forgeability is handled by #302's scoping: the candidate set is filtered to authorized publishers beforeMAX(dct:created), so a postdated nanopub from an unauthorized key can't win. Among co-equal admins it's "latest claimed time wins" — a shared-clock-trust assumption, fine within a mutually-trusting admin set.
Revocation vocabulary
Both forms are stative (assert a state, not an action), mirroring the existing past-participle marker gen:DeactivatedPresetAssignment. Each is a positive assertion (RDF can't assert negation); the "no longer" is emergent — it's the latest authorized assertion superseding a prior positive, exactly as gen:hasRole's "currently has" is emergent. Two layers, split by arity:
3-component key (space, agent, role) → a typed node, in its own nanopub:
:r a gen:RevokedRoleInstantiation ;
gen:forSpace <space> ;
gen:forAgent <agent> ; # whose role is revoked
gen:hasRole <roleIRI> . # the role revoked (version-pinned, matches the materialized key)# pubinfo carries dct:created (the latest-wins key) + the signature (= the revoker)
One form covers everything except admin. A predicate/back-compat role only materializes via a declared gen:hasRole attachment (nonAdminTierUpdate anchors on it; the materialized row carries gen:hasRole ?role, AuthorityResolver.java:1407), so every non-admin role has a role IRI to key on. gen:hasAdmin is itself a back-compat predicate (BackcompatRolePredicates.java:64) — not a special case.
Admin is the sole exception:adminTierUpdate materializes admin RIs from the hasRootAdmin seed / closed-over grants with npa:hasRoleType gen:AdminRole and no gen:hasRole (lines 860-871). Admin revocation therefore keys on the admin tier (gen:hasAdmin / AdminRole); root admins are un-revokable (below).
2-component key (space, role) → a single predicate triple, the stative antonym of gen:hasRole:
<space> gen:detachedRole <roleIRI> . # role no longer available in the space; admin-authored
Mirrors the positive gen:hasRole attachment, which is itself predicate-detected (SpacesExtractor.java:414). Past-participle reads as a resulting state, consistent with the Deactivated… convention.
Authorized by admins (matching who may attach). Cascades: a winning gen:detachedRole removes the role's availability, so all instantiations anchored on that attachment drop out (the structural-rebuild case).
Applies to preset-derived attachments too. Preset-bundled roles materialize as the samegen:RoleAssignment shape (forSpaceRef + gen:hasRole, differing only by npa:derivedFromPreset, AuthorityResolver.java:1082-1088), so they share the (space, role) key. gen:detachedRole competes against both direct and preset-derived rows — giving per-role opt-out, which preset deactivation (all-or-nothing over the whole bundle, scoped to derivedFromPreset) can't. Keyed on (space, role), not (space, role, preset): "R is not a role here, whatever supplies it." Implementation: presetAttachmentValidationUpdate's INSERT gains FILTER NOT EXISTS { newer authorized gen:detachedRole for (space, role) } + displacement DELETE; the comparison uses the preset assignment's dct:created (:1099) as the derived attachment's effective timestamp.
DECISION — sticky vs. latest-wins on preset re-assignment. Under plain latest-wins, a newergen:PresetAssignment (later dct:created than the detach) re-attaches R. If a detach should survive preset re-assignment ("sticky opt-out"), that needs a per-(space, role) suppression that outranks the bundle rather than competing on timestamp — a deliberate extra rule. Default for v1: plain (non-sticky) latest-wins.
(Role-half only — a preset's views are read-time in Nanodash, unaffected by server-side detach.)
Common to both
No new authority/ordering fields:dct:created (pubinfo) is the latest-wins key; the signature is the actor. A self-revoke (leave) is simply signer == forAgent (instantiation form only).
Both are new, previously-unused terms → back-compat (below).
Wiring: extractor recognizes the new term (add to the type/predicate set at SpacesExtractor.java:65) and emits a negative row — keyed like a RoleInstantiation (instantiation form) or RoleAssignment (attachment form) + dct:created + pubkeyHash, parallel to the DeactivatedPresetAssignment path (SpacesExtractor.java:905-915). The negative competes in the per-key latest-wins at the materialization tier where the key is bound — nonAdminTierUpdate (roles), adminTierUpdate (admin), attachmentValidationUpdate (attachments) — not at extraction, since a predicate-keyed instantiation only acquires its role IRI during the declaration join.
Root admins are un-revokable (strict)
Root admins (npa:hasRootAdmin in the root gen:Space nanopub, SpacesExtractor.java:288-300) are constitutional: sourced only from the root-def seed (#110), never assignable or revocable at runtime — no negative assertion can win against them. Removal is only via the root-def / ref lifecycle, so the root cohort is permanent for the ref's lifetime (continuation revisions carry no hasRootAdmin; editing root admins means migrating the ref). Downward powers unchanged — they stay in the admin set.
Exempt them by deriving on demand, not a stamp — the resolver already reads hasRootAdmin from the spaces graph:
No re-stamp, no re-ingest, zero consumer impact (admins look as today). Trade-off: root-admin-ness isn't a stamped fact, so a consumer wanting to surface it must join hasRootAdmin itself.
Backwards compatibility
Fully back-compat, given an unused negative-assignment vocab:
No existing nanopub is reinterpreted — nothing already published matches the new vocab, so all current memberships stay active.
Active-by-default overlay, not a rewrite — state = sign of the latest authorized, non-invalidated assertion on the key; with zero negatives in existing data the latest is always positive → identical to today. npx:invalidates keeps working unchanged (nanopub-level undo).
No state-graph shape change — negatives never materialize as rows (they act via the suppression filter / displacement DELETE), root admins use derive-on-demand → active-member rows look as today, no consumer repoint.
No re-ingest — nothing to backfill; extractor + resolver changes take effect on the normal periodic rebuild from already-ingested data (a redeploy, not a re-sync).
Rollout realities (not format breaks):
Mixed-version fleet — until all servers upgrade, a revocation is honored only on upgraded ones; old servers don't know the vocab and fail open (keep the member). Full effect once the fleet is upgraded.
dct:created well-definedness — ensure positives carry it (or define a fallback ordering), since the latest-wins comparison goes live the moment a negative touches that key.
Notes
Griefing surface is admin↔admin only (self-perpetuating admin set; root admins exempt). Lower tiers get no peer-revoke; self only touches its own role.
dct:created is globally consistent across the fleet (unlike load number, which diverges on resync) — why #302 uses it.
Port #302's resolver; canary against the published get-space-members / role-listing query nanopubs (raced /api, first-2xx-wins) before merge.
Problem
A granted role can currently be revoked only by the original granter (same signing key — #112's
samePublisherClause,AuthorityResolver.java:819). So an admin can't revoke a member another admin admitted, and a member can't leave on their own.Model: assign/revoke as authorization-scoped latest-wins
State of an
(agent, role, spaceRef)key = the sign of the latest authorized assertion bydct:created(subject-IRI tiebreak), active-by-default. This is exactly what preset assignments already do (#302): authorization-scoped latest-wins, notnpx:invalidates(AuthorityResolver.java:1064-1068, 1173-1198).Only new primitive: key-scoped negative assertions (see Revocation vocabulary below) — mirror
gen:DeactivatedPresetAssignment(GEN.java:130; active-by-default, a revocation is just a newer authorized row) — one for instantiations(agent, role, spaceRef)and one for attachments(role, spaceRef), both carryingdct:created.npx:invalidates, which stays for nanopub-level undo ("this exact statement was a mistake"); the negative assignment is key-level ("agent no longer holds this role, however granted").Authorization filter — who may publish a winning assertion: whoever's tier could grant the role, plus the assignee for their own role.
MAX(dct:created), so a postdated nanopub from an unauthorized key can't win. Among co-equal admins it's "latest claimed time wins" — a shared-clock-trust assumption, fine within a mutually-trusting admin set.Revocation vocabulary
Both forms are stative (assert a state, not an action), mirroring the existing past-participle marker
gen:DeactivatedPresetAssignment. Each is a positive assertion (RDF can't assert negation); the "no longer" is emergent — it's the latest authorized assertion superseding a prior positive, exactly asgen:hasRole's "currently has" is emergent. Two layers, split by arity:Instantiation revocation (agent → role):
gen:RevokedRoleInstantiation3-component key
(space, agent, role)→ a typed node, in its own nanopub:gen:hasRoleattachment (nonAdminTierUpdateanchors on it; the materialized row carriesgen:hasRole ?role,AuthorityResolver.java:1407), so every non-admin role has a role IRI to key on.gen:hasAdminis itself a back-compat predicate (BackcompatRolePredicates.java:64) — not a special case.adminTierUpdatematerializes admin RIs from thehasRootAdminseed / closed-over grants withnpa:hasRoleType gen:AdminRoleand nogen:hasRole(lines 860-871). Admin revocation therefore keys on the admin tier (gen:hasAdmin/AdminRole); root admins are un-revokable (below).Attachment revocation (role → space):
gen:detachedRole2-component key
(space, role)→ a single predicate triple, the stative antonym ofgen:hasRole:gen:hasRoleattachment, which is itself predicate-detected (SpacesExtractor.java:414). Past-participle reads as a resulting state, consistent with theDeactivated…convention.gen:detachedRoleremoves the role's availability, so all instantiations anchored on that attachment drop out (the structural-rebuild case).gen:RoleAssignmentshape (forSpaceRef+gen:hasRole, differing only bynpa:derivedFromPreset,AuthorityResolver.java:1082-1088), so they share the(space, role)key.gen:detachedRolecompetes against both direct and preset-derived rows — giving per-role opt-out, which preset deactivation (all-or-nothing over the whole bundle, scoped toderivedFromPreset) can't. Keyed on(space, role), not(space, role, preset): "R is not a role here, whatever supplies it." Implementation:presetAttachmentValidationUpdate's INSERT gainsFILTER NOT EXISTS { newer authorized gen:detachedRole for (space, role) }+ displacement DELETE; the comparison uses the preset assignment'sdct:created(:1099) as the derived attachment's effective timestamp.gen:PresetAssignment(laterdct:createdthan the detach) re-attaches R. If a detach should survive preset re-assignment ("sticky opt-out"), that needs a per-(space, role)suppression that outranks the bundle rather than competing on timestamp — a deliberate extra rule. Default for v1: plain (non-sticky) latest-wins.Common to both
dct:created(pubinfo) is the latest-wins key; the signature is the actor. A self-revoke (leave) is simplysigner == forAgent(instantiation form only).SpacesExtractor.java:65) and emits a negative row — keyed like aRoleInstantiation(instantiation form) orRoleAssignment(attachment form) +dct:created+pubkeyHash, parallel to theDeactivatedPresetAssignmentpath (SpacesExtractor.java:905-915). The negative competes in the per-key latest-wins at the materialization tier where the key is bound —nonAdminTierUpdate(roles),adminTierUpdate(admin),attachmentValidationUpdate(attachments) — not at extraction, since a predicate-keyed instantiation only acquires its role IRI during the declaration join.Root admins are un-revokable (strict)
Root admins (
npa:hasRootAdminin the rootgen:Spacenanopub,SpacesExtractor.java:288-300) are constitutional: sourced only from the root-def seed (#110), never assignable or revocable at runtime — no negative assertion can win against them. Removal is only via the root-def / ref lifecycle, so the root cohort is permanent for the ref's lifetime (continuation revisions carry nohasRootAdmin; editing root admins means migrating the ref). Downward powers unchanged — they stay in the admin set.Exempt them by deriving on demand, not a stamp — the resolver already reads
hasRootAdminfrom the spaces graph:No re-stamp, no re-ingest, zero consumer impact (admins look as today). Trade-off: root-admin-ness isn't a stamped fact, so a consumer wanting to surface it must join
hasRootAdminitself.Backwards compatibility
Fully back-compat, given an unused negative-assignment vocab:
npx:invalidateskeeps working unchanged (nanopub-level undo).Rollout realities (not format breaks):
dct:createdwell-definedness — ensure positives carry it (or define a fallback ordering), since the latest-wins comparison goes live the moment a negative touches that key.Notes
dct:createdis globally consistent across the fleet (unlike load number, which diverges on resync) — why #302 uses it.get-space-members/ role-listing query nanopubs (raced/api, first-2xx-wins) before merge.References
AuthorityResolver.java:1064-1068, 1173-1198(authorization-scoped latest-wins),:602-625(tier wiring),:819(samePublisherClause),:860-871(admin RI: nogen:hasRole),:979(attachmentValidationUpdate),:1082-1088(preset-derivedRoleAssignment/derivedFromPreset),:1407(non-admin RI carriesgen:hasRole);GEN.java:130(DeactivatedPresetAssignment);SpacesExtractor.java:65(type/predicate set),:288-300(root admins),:414(gen:hasRolepredicate-detected),:905-915(deactivation extraction);BackcompatRolePredicates.java:64(hasAdminis a back-compat predicate)