07 Permissions and authorization¶
Status: draft M0. Normative for the module authz, for every route, every WebSocket op, every
delivery and every agent invocation.
Terms: 00-glossary.md. Tables: 03-data-model.md.
Events and catch-up: 04-sync-protocol.md. Actions, receive actions
and rate-limit classes: 05-actions.md. Identity, sessions and step-up:
08-identity.md. Threats: ../security/threat-model.md.
Decisions: ADR 0017 (one authorization function), ADR 0018 (grant model).
This chapter defines how authorize(principal, action, resource) -> Decision decides. The
action catalogue (which action needs which level) is in 05 and is not repeated here.
The additions requested during drafting are now in the core contract (section 13).
1. Principles¶
- The default is nothing. Without a valid grant, owner status or DM participation, a principal has no level on a target. There are no deny rows; the highest valid level applies.
- One truth. Only
authzinterprets grants. Other modules get decisions or the set of visible channels, never grant rows (03, principle 12). - Time is checked on every call. Expiry dates of grants and group memberships take effect from the second of expiry, without a job having to run. Jobs only report the expiry.
- Permissions apply at delivery. What a recipient gets is decided by
authorizeat the moment of delivery, not at the moment of the event (04, section 8). - Fail closed. Every error in
authz(database unreachable, unknown action, unknown resource type) ends in denial, never in permission. - Guard rules rank above grants. Section 5 names rules that no grant overrides.
- Whoever may trigger an agent reaches through it everything the agent can read. Therefore
authzbinds agent invocations to the permissions of the triggering person (guard rule SR9) and shows the read horizon of every agent openly (section 6).
2. Grant model¶
2.1 Subjects¶
| Subject | Code | May receive grants | Highest level | Capabilities |
|---|---|---|---|---|
| Person | user |
yes | trigger |
all |
| Group | group |
yes (applies to its valid members) | trigger |
all |
| Agent | agent |
yes | write (R-DM-08) |
only upload (R-DM-20) |
| App | app |
yes | write (R-DM-08) |
only upload (R-DM-20) |
Groups contain only people (R-DM-07). Groups are not acting principals.
Rationale for "agents and apps only upload": approve_actions on an agent would mean that an
agent could confirm its own approval cards. manage would mean that an agent issues grants
and reads channel prompts. export would mean that an agent pulls histories in bulk. An agent
needs none of these for its work. The table grants enforces this with a check (03,
R-DM-20).
2.2 Targets and inheritance¶
Targets are instance, space and channel (03, grants.target_kind). Threads and everything in
them have no grants of their own; they inherit from the channel. Hosts (host) are a fourth target
outside this tree (section 2.12). The path of a resource is in 05,
section 2.2.
instance
└── space
└── channel (text or forum)
└── thread
└── message, run, run_step, component, interaction, agent_session
DM channel: outside the tree, only dm_participants
A grant on a target applies to the target and everything below it. On a channel, the highest level of all valid grants on the channel, space and instance applies. A channel can therefore never allow less than its space. Whoever needs "space Support except channel Eskalation" gives grants on the individual channels instead of on the space (ADR 0018).
2.3 Levels¶
Levels are ascending and include the lower ones: read < write < trigger.
| Level | Meaning (short form; the column "Permission" in 05 is authoritative) |
|---|---|
read |
see space, channel, threads, messages, files, runs; search; maintain own read state, notifications |
write |
in addition send, edit and delete messages (own), create, close and reopen threads, reactions, drafts, typing |
trigger |
in addition trigger agents, operate components, run commands, cancel own runs, start a new agent session or bind one |
write never triggers an agent. Only trigger does (study C, adopted in Baseline
section 3).
2.4 Capabilities¶
| Capability | Purpose | Also requires |
|---|---|---|
upload |
upload and attach files (file.upload, attachments to message.create) |
write |
approve_actions |
operate components with required_capability = 'approve_actions' (approval cards) |
trigger |
export |
export the channel history (export.channel) |
read |
manage |
administration of the target: create and change channels, behavior, tags, pins, delete other people's messages, move threads, incoming webhooks, "Who has access?", grants within one's own scope (section 2.6) | any level |
terminal |
open a terminal on a host (terminal.open, terminal.attach); only on the target host, only for people and groups (section 2.12) |
no level; SR10, SR11 and SR12 |
Capabilities are the union over all valid grants of the principal and its valid groups on the path (05, section 2.3). A capability never replaces the required level.
2.5 Validity of a grant¶
A grant g is valid at time t if and only if:
g.revoked_at IS NULL,g.expires_at IS NULL OR g.expires_at > t,- the subject is active (
principals.status = 'active'), for groups:groups.deleted_at IS NULL, - for group grants for person
u: there is a membershipmwithm.removed_at IS NULLandm.expires_at IS NULL OR m.expires_at > t, - for app subjects: the installation is
active.
Boundary: at expires_at = t the grant is already invalid. t is the server clock, read once per
authorize call, so that all checks of one call see the same time.
Grants are never changed, only revoked and created anew (R-DM-09). An extension is therefore a revocation plus a new grant with a new reason.
2.6 Delegation with manage¶
Whoever has manage on a target Z may create and revoke grants on Z and below it, with
these limits (R-AZ-05, made precise):
- The level of the new grant is at most one's own effective level on the target of the new grant.
- The capabilities of the new grant are at most one's own effective capabilities there (
manageincluded; whoever hasmanagemay pass it on). - Revocation only for grants that one could have created oneself under 1 and 2. Otherwise a
person with
writeandmanagecould remove the stronger grant of another person that the owner set (05grant.revoke, R-AZ-05). - Subjects only people and groups. Only the owner creates grants to agents and apps.
Reason: a grant to an agent extends what its runtime reads and sends to an
LLM provider. That is a data flow to a processor and a change of the
read horizon (20-privacy.md, section 6). (05
grant.create.) - Only the owner creates grants on the instance.
- Reading (
grant.read): owners see all grants. Whoever has onlymanagesees the grants on targets on which they havemanage, and below them; filters by target or subject narrow this set, they never widen it. A filter on a subject therefore does not show its grants outside one's own scope.
Memberships in groups with source = 'oidc_claim' are determined solely by the group claim of the
provider (08-identity.md, section 3.4); group.member.add and
group.member.remove on such groups end with 409 conflict and reason:
claim_managed_group, because the next login would otherwise silently overwrite a manual change.
2.7 Owner¶
- Owners have the level
triggerand all capabilities on every target except DM channels (05, section 2.3), independent of grants. - Only active owners may perform owner actions (column "Owner" in 05).
- At least one active owner always remains (guard rule SR8).
- The first owner is created only by a server command (08-identity.md,
section 4). Further owners only by
owner.addthrough an owner with step-up. - Owners do not read other people's direct messages (R-AZ-02, guard rule SR6).
2.8 Direct messages¶
DM channels have no space and no grants (R-DM-10). Participants with left_at IS NULL
have write and upload. Nobody else sees the channel, not even an owner. There are no agents
in DM channels.
2.9 Agents as principals¶
- Agents receive grants like people, at most
writeand onlyuploadas a capability. - Operator rule. An agent has at least
writeon every channel whose effective operator it is, and on every thread withthreads.operator_agent_idequal to it, without a grant row. Reason: without this rule, every operator change would have to entail a second, easily forgotten grant; on a change, the old grant would remain in place. The rule follows the operator automatically (05, section 2.3 item 4; R-AZ-13). - Agents never trigger by themselves. Their messages have
origin = 'agent'(guard rule SR4). - Agents perform no step-up actions (R-AZ-03).
- Agents implicitly have all scopes except
users:readandcosts:reconcile(05, section 2.6); their limit is the grants and the operator rule. - If an agent acts within a run, the acting-for binding (SR9) applies in addition.
2.10 Apps¶
An app needs both for every action:
- an effective level from grants (at most
write) and - the scope of the action in
app_installations.granted_scopes(05, section 2.6) and, when the request carries an API token, also in itsapi_tokens.scopes(08-identity.md, section 12).
An app without an active installation (suspended or uninstalled) has an empty permission state,
like a principal that is not active: app.suspend and app.uninstall take every visible target
from it, app.resume gives them back, and the permission change writes access.changed for it.
Visibility follows the level: if an app has a grant on a channel, the channel is visible to it.
If it lacks only the scope, the response is 403 with reason scope_missing, not 404;
the app knows the channel from its grant anyway. Incoming webhooks write as their app, with
origin = 'app', and never trigger.
2.11 Tool class of an agent¶
Permissions govern who reaches an agent. What the agent can then do is determined by its runtime. So that the server can draw a limit for this, every agent has a tool class:
| Class | Code | What the runtime may do | Worst abuse |
|---|---|---|---|
| Conversation | chat |
reply, read the thread context, no effect outside the thread | wrong or unwanted text in the thread |
| Scoped | scoped |
named tools with their own, minimal credentials; effect on the outside only after an approval card | one named, limited effect |
| Host | host |
shell, free file system or free network access with the permissions of the runtime user (for example a coding agent with tool permission prompts switched off) | takeover of the host on which the runtime runs, and of all secrets reachable there |
Data model (03, agents): tool_class text NOT NULL DEFAULT 'chat' CHECK (tool_class IN
('chat','scoped','host')) and reported_tool_class text NULL. Setting it is the action
agent.tool_class.set (Owner, Step-up, Audit, Permissions yes).
Configured and reported value as with the backend policy: the invocation token carries tool_class
(configured). The adapter reports its actual class in the heartbeat and starts no run if the actual
class is higher than the configured one (reason tool_class_mismatch). The order chat < scoped <
host is authoritative here. The protocol is defined in 09-agents.md; the claim
tool_class is in 05, section 5.
2.12 Hosts and the terminal capability¶
Normative together with 24-terminal.md, section 8.
- A host is a target of its own. It inherits nothing from instance, space or channel; a grant on the instance gives no terminal (R-AZ-24).
- On a host there is no level and exactly one capability:
terminal. Recipients are only people and groups (03, R-DM-35). Only the owner creates grants there. -
Effective
terminal(P, H)for a personPon the hostH:Pis an active owner in a session, and not (H.llm_credentials = 'personal'andP <> H.credential_holder_id); or- a valid grant (2.5) with target
HandterminaltoPor one of their valid groups.
The exception of the first item (an owner on a host with
personal) is decided by the guard rule SR11 with the reasoni6_holder_only(section 4.2, step 9; M2-40); the atom+terminalof the requirement does not repeat it (docs/DECISIONS.md D-M2-39-3). -
Effective
terminalis not enough: SR10, SR11 and SR12 (section 5) block afterwards. The preview of a grant that does not take effect because of SR10 or for lack of a session user (SR12) says so (hintterminal_not_effective). - Reading a host (
host.read): owners, the host assignee (hosts.assigned_user_id) and anyone with a valid grantterminalon it, even if SR10 blocks it (so that the person sees the reason). Otherwise404.
3. Principal context¶
authorize receives not just an ID, but the verified context of the request. The
authentication (08-identity.md) builds it; authz trusts only this context.
| Field | Content | Source |
|---|---|---|
id, kind |
principal and kind (user, agent, app, anonymous, host); host is a host, logged in through its login key (24-terminal.md, section 4.1), without a row in principals |
token, or login of the host |
session_id |
auth_sessions.id (people only) |
sid in the access token or console cookie |
device_id |
device (people only) | token, DPoP key |
step_up_at |
last fresh confirmation of the session | auth_sessions.step_up_at |
token_id, token_scopes |
API token and its scopes (agents, apps, personal tokens) | api_tokens |
installation_id, granted_scopes |
apps only | app_installations |
acting_for |
triggering person, run, thread and jti, if an agent acts within a run |
verified invocation token in the request header (guard rule SR9); the header is Invocation-Token (06-api.md, sections 3 and 4) |
view_as |
person whose view the owner is currently looking at | session with active "View as person" (section 9.2) |
request_time |
one server time per call | clock |
A personal API token (switch tokens.personal_enabled) acts as its person, but without a
session. It can therefore never provide step-up.
For the check without a request, the server builds the context itself, not the authentication:
id and kind = user of the person and request_time, without all other fields (05, section 5,
"Check without a request"; R-AZ-29).
4. The authorize function¶
4.1 Decisions and reasons¶
authorize returns a decision from 05, section 2.5, and on denial a reason code.
The code is in the audit log (audit_events.deny_reason), and in error responses only where it
reveals nothing (never with 404).
| Reason | Decision | Meaning |
|---|---|---|
unauthenticated |
unauthenticated |
no valid principal |
principal_inactive |
unauthenticated |
principal disabled or deleted |
principal_pending |
forbidden |
provisioned person in state pending (08-identity.md, section 3.1); problem code device_pending; the same actions are allowed as for a waiting device |
session_revoked |
unauthenticated |
session revoked or expired |
device_inactive |
unauthenticated |
device revoked or key revoked |
device_pending |
forbidden |
problem code device_pending; the device is waiting for approval; only self.read, device.list for the own device, auth.token.refresh, auth.logout are allowed (05, section 2.5) |
token_revoked |
unauthenticated |
API token revoked or expired |
app_not_installed |
unauthenticated |
app installation not active |
no_grant |
not_found |
no read permission, or the resource is missing; also an app command whose app is not reachable (installation not active, no commands:write, or no write on the channel where one is resolved), answered like an unknown command (09-agents.md section 10.3, M2-25) |
level_insufficient |
forbidden |
level too low |
capability_missing |
forbidden |
capability missing |
scope_missing |
forbidden |
app without the scope |
owner_only |
forbidden |
action only for owners |
not_self |
forbidden or not_found |
the resource belongs to someone else (device, setting); not_found if the principal may not see it |
subject_kind_not_allowed |
forbidden |
agent or app not allowed as actor or recipient |
delegation_exceeds_own_level |
forbidden |
delegation beyond one's own level |
delegation_exceeds_own_capabilities |
forbidden |
delegation beyond one's own capabilities |
step_up_required |
step_up_required |
fresh confirmation missing |
step_up_not_possible |
forbidden |
token without a session (agent, app, personal token); also createFileTicket without a login session, because a download ticket binds to a login session (12-files.md section 9.2) |
view_as_read_only |
forbidden |
writing action during "View as person" |
i1_violation |
invariant_violation |
the change would violate I1 |
last_owner |
invariant_violation |
the change would leave no active owner |
origin_not_human |
forbidden |
triggering by an agent, app, import or the system |
agent_not_reachable |
forbidden |
the agent is not the operator, not mentioned, or the mention is not allowed |
billing_mode_personal_not_holder |
forbidden |
I2 |
tool_class_owner_only |
forbidden |
SR2 |
dev_environment_not_ready |
forbidden |
SR3 |
dev_environment_foreign_thread |
forbidden |
SR3, the thread runs in someone else's environment |
project_not_ready |
forbidden |
project binding not ready (05, section 5, item 7) |
channel_read_only |
forbidden |
channel read-only; writing and triggering actions blocked, even for owners (05, rule 1.8); problem code channel_read_only |
channel_archived |
forbidden |
channel or its space archived; writing and triggering actions blocked, even for owners (section 4.5); problem code channel_read_only with reason |
run_active_other_agent |
forbidden |
the thread has an active run of another agent (05, section 5, item 8) |
run_active |
forbidden |
model command during an active run (05 command.invoke); also interaction.create on a component without run while the card's agent has an active run in the thread, which would need a second run (R-DM-14, 09-agents.md section 11.2, M2-15); problem code agent_invocation_denied with reason |
billing_mode_mismatch |
invariant_violation |
run.start with a reported mode not equal to the configured mode (I3); problem code billing_mode_mismatch (409) |
tool_class_mismatch |
invariant_violation |
the adapter reports a higher tool class than the token; problem code tool_class_mismatch (409) |
invocation_token_invalid |
forbidden |
invocation token invalid, expired, not the current one, or consumed (run.start, run.token.renew); reason expired, superseded or invalid (09-agents.md, section 4.5); also every request with Invocation-Token (SR9), with reason invalid (token, run, agent or person wrong, or the header repeated, empty or sent by a principal that is not an agent) or run_inactive (the run of the token has ended) |
import_not_running |
forbidden |
import.apply without a running source or with a target outside its channels |
malware_detected |
no authorize result |
reason of the audit entry for file.upload on a malware finding (12-files.md, section 5.2) |
component_inactive |
forbidden |
component used, expired or deactivated; also a component whose run has ended, and a component of an app without an active installation, without components:write or without write on the channel (M2-25). Answered 409 component_used without the reason, which stays in the audit, so a lost race answers the same whether authorize or the single-use lock decided it (09 section 11.2, M2-15) |
component_restricted |
forbidden |
restricted_to = 'run_starter' and the principal is neither the triggering person nor an owner; also restricted_to = 'owner' and the principal is not an effective owner ("Always allow", 09 section 11.5, M2-15); problem code forbidden with reason |
acting_for_insufficient |
not_found or forbidden |
acting-for binding: the triggering person does not have the permission; not_found when the target is visible with the agent's own rights but not with the intersection (answered like an unknown ID), forbidden (problem forbidden with reason: acting_for_insufficient) when the requirement holds with the agent's own rights but fails with the intersection; if the agent alone already fails, the agent's own reason stays |
terminal_not_host_assignee |
forbidden |
SR10: a non-owner on a host whose assignee they are not; problem code terminal_denied with reason: not_host_assignee |
terminal_server_host_owner_only |
forbidden |
SR10: server host only for owners; problem code terminal_denied with reason: server_host_owner_only |
i6_holder_only |
forbidden |
SR11: host with a personal LLM login, the principal is not its holder; problem code terminal_denied with reason: i6_holder_only |
i6_violation |
invariant_violation |
the change would violate I6; problem code i6_violation (409) |
terminal_no_session_user |
forbidden |
SR12: the person has no valid mapping on the host to an own session user that the host reports, even as an owner; problem code terminal_denied with reason: no_session_user |
terminal_profile_unknown |
forbidden |
profile not in hosts.reported_profiles, or an index of no working directory of the own session user (hosts.reported_session_users); problem code terminal_denied with reason: profile_unknown |
terminal_session_limit |
forbidden |
terminal.max_sessions_per_person or terminal.max_sessions_per_host reached; problem code terminal_denied with reason: session_limit |
terminal_step_up_not_fresh |
step_up_required |
step-up older than terminal.step_up_max_age, already consumed or (console) not by passkey; in the console the quick reattach without step-up never applies |
host_not_active |
forbidden |
host not active or host service not connected; problem code host_unavailable (409) |
host_auth_failed |
unauthenticated |
login of a host with an unknown key or a wrong signature |
host_duplicate_connection |
forbidden |
a second live control connection with the same keys of a host whose credentials are valid (24 3.4, M2-43); the host is disabled or falls back to pairing, both connections close with 4112 |
unknown_action |
error 500 |
program error, alert |
trusted_header_requires_loopback |
no authorize result |
Reason of the audit entry of identity_provider.manage when enabling the driver trusted_header is refused because the public listener is not bound to loopback; problem code conflict (409) with reason: trusted_header_requires_loopback (08-identity.md, section 2.4, PLAN W4) |
4.2 Flow¶
authorize(p, action, ref) -> Decision:
t := now() # one time per call
spec := catalog[action]
if spec missing: alarm(); return ERROR(unknown_action)
# 1. Who is acting? (blocking checks)
if p.kind == anonymous:
return spec.right == public ? ALLOW : DENY(unauthenticated)
d := check_active(p, t) # status, session, device, token, installation
if d: return d # unauthenticated with a reason
if p.status == pending and action not in PENDING_ALLOWED:
return DENY(forbidden, principal_pending) # provisioned, 05 section 2.5
if p.device_state == pending and action not in PENDING_ALLOWED:
return DENY(forbidden, device_pending) # 05, section 2.5
if p.acting_for.status == invalid: # SR9, before resolve: unknown and invisible IDs alike
return DENY(forbidden, invocation_token_invalid, detail invalid)
if p.acting_for.status == run_inactive and action not in SR9_UNBOUND:
return DENY(forbidden, invocation_token_invalid, detail run_inactive) # SR9
# 2. Permission snapshot (section 7)
snap := snapshot(p, t) # cache per (p, permission_version), valid until snap.valid_until
# 3. Resource and path to the target
res := resolve(ref) # path thread -> channel -> space -> instance, DM flag, owning principal
if res missing: return DENY(not_found, no_grant)
# 4. Effective permissions on the path
eff := effective(snap, res.path) # owner, DM, grants, groups, operator rule,
# highest level, union of the capabilities,
# limit for agents and apps
if p.acting_for.status == bound and sr9_applies(action, p.acting_for, res) and not delivery_mode:
eff := eff ∩ effective(snapshot(p.acting_for.user, t), res.path) # SR9
if p.view_as: eff := eff ∩ effective(snapshot(p.view_as, t), res.path) # section 9.2
# 5. Visibility: no read permission results in 404
if not visible(spec, p, eff, res): return DENY(not_found, no_grant | acting_for_insufficient)
# 6. Requirement from the catalogue (columns "Permission", "Owner")
d := requirement(spec, p, eff, res) # level, capability, scope, self, owner, participant
if d: return d # forbidden with a reason
# 7. No writing in "View as person"
if p.view_as and spec.writes: return DENY(forbidden, view_as_read_only)
# 8. Step-up
if spec.step_up == yes or (spec.step_up == conditional and has_credentials(p, action)):
if p.kind != user or p.session_id missing: return DENY(forbidden, step_up_not_possible)
if p.step_up_at missing or t - p.step_up_at > auth.step_up_max_age:
return DENY(step_up_required)
if spec.step_up == conditional and (p.kind != user or p.session_id missing):
return DENY(forbidden, step_up_not_possible) # first setup only in a session
if spec.step_up == fresh: # 05 section 2.4; 08 section 6.3
if p.kind != user or p.session_id missing: return DENY(forbidden, step_up_not_possible)
if not quick_reattach(p, action, res, t): # only terminal.attach, never in the console
if p.step_up_at missing or t - p.step_up_at > terminal.step_up_max_age
or p.step_up_method not in {passkey, device_key}
or (p.client_component == console and p.step_up_method != passkey with userVerification)
or (p.step_up_consumed_at set and p.step_up_consumed_at >= p.step_up_at):
return DENY(step_up_required, terminal_step_up_not_fresh)
# the confirmation is consumed in the transaction of the action (08 section 6.3)
# 9. Guard rules of the action (section 5)
d := guards[action](p, res, eff, snap, t) # for example agent.invoke: SR1 (I2), SR2, SR3, SR4
if d: return d
return ALLOW
SR9 in steps 1 and 4: SR9_UNBOUND is run.start (also resumeRun) and run.token.renew; with
a valid token they answer exactly as without the header, also when the run has ended; an invalid
header denies them like every action (step 1). sr9_applies is false
for these two actions and for the own exceptions of SR9 (message.create, run.update,
run.finish, interaction.respond, agent_session.report) when the resolved thread of the
resource is the run's thread and, for a resource of kind run, the resource is the run itself;
for every other action it is true (fail closed). In delivery mode (authorize.Delivery(ctx), the
REST poll and the gateway delivery to an agent consumer) decisions and Scope ignore acting_for,
because an agent consumer receives by its own grants (8.1 row "Agent consumers"); the filter mode
(authorize.Filter(ctx)) stays bound. Scope with status invalid or run_inactive returns the
same invocation_token_invalid decision.
While a view is stored on a console session, access.view_as.end, auth.logout and
auth.step_up on the person's own session and self.read on the person themselves are decided
without view_as (R-AZ-32; steps 4 and 7 would otherwise deny them).
After that, not in authorize, come the audit and the rate-limit counting according to the columns
in 05. Rate limits are checked by a separate layer before authorize (05, section 2.5).
Order and reason:
- Blocking checks before everything else: a disabled principal never gets any information about resources.
- Visibility before requirement: whoever may not read gets
404, even if they also lack a level. - Requirement before step-up: whoever does not have the permission anyway is not asked for confirmation. Otherwise the step-up prompt would reveal that the action would be possible.
- Guard rules last: they need the full context (level, run, thread) and apply to owners too, where they say so.
Guard rules of the terminal in step 9 (M2-40), in this order, the first violation decides: SR10
server host (terminal_server_host_owner_only), SR10 assignee (terminal_not_host_assignee), SR11
(i6_holder_only), SR12 (terminal_no_session_user). Reason: the order of section 5 and
24-terminal.md, section 6.1 step 2; a server host is closed to every non-owner,
assigned or not, so its reason names what would help (docs/DECISIONS.md D-M2-40-3). The guard rules
run after the step-up: whoever has no fresh step-up learns step_up_required first. The atom
+terminal of the requirement is effective terminal after 2.12 item 3 without the I6 exception
for owners; an owner who is not the holder of a personal login fails at SR11. M2-44
(host_not_active, terminal_profile_unknown) and M2-46 (terminal_session_limit) extend the
list after it.
Exception to the order: if the requirement of an action is All(HumanSession, ...) (host.read,
host.manage, from M2-40 also terminal.*), authorize checks right after step 1 and before the
rights state, resolution and visibility whether a person acts in a session; agents, apps and
personal tokens get forbidden with subject_kind_not_allowed. Reason: 05 section 4.14 rejects
them with this reason, and the visibility before it would turn that into 404. The answer depends
only on the principal, never on the resource, so it reveals nothing about a host (AZ-P4;
docs/DECISIONS.md D-M2-39-4).
Requirements that need facts of a later milestone (runs, sessions and commands of the
agents, components, files, webhooks, import) stand until then as deferred entries in the
catalogue of the code: they behave like an unknown action (500, alert), and the
startup check rejects every route, op or event type with such an action. The ticket
that delivers the facts replaces the entry in the same change. Reason: the catalogue is closed
from M1 on (AZ-P3), and a requirement that cannot be checked yet must never allow. conditional in
the column "Step-up" (05, section 2.4) counts as yes until the ticket that delivers the condition.
has_credentials(p, action) is a question to PrincipalSource: for passkey.register a
passkey of the person with revoked_at IS NULL, for local_credential.set a row in
local_credentials with totp_enabled_at IS NOT NULL (an established password path). The rule
itself stays in authz (B7).
Actions with two targets (thread.move: thread and target channel, permission "manage on source and
target"; agent_session.bind: thread and the thread in which the reference is current, permission
"trigger and agent.invoke on the thread and, when the reference is current in another thread, also on that thread",
09 section 9.4) carry the second target in the resource reference (ref.target). resolve resolves both
paths; visibility and requirement apply on both. Without read permission on the target the response
is 404, the same as for an unknown ID. Reason: a second check in the use case would be permission
logic outside of authz (05, rule 1.1).
Audit according to the column "Audit" in 05: authorize passes every denial and every error to
kernel/auditing; they are written after the rollback in a separate transaction and aggregated
per audit.deny_aggregate_window (AZ-P13). Filters are exempt: the
receive check per recipient during delivery and catch-up (gateway, GET /api/v1/events, push,
webhooks) and the check per element of a list, search or snapshot (7.4, rule 3). They
run in filter mode (authorize.Filter(ctx), 02 section 4.5) and write no denial.
Reason: otherwise every event would write a row for every non-recipient; what is checked is
requests and ops. Allow decisions of an action with audit yes
are written by its use case in its transaction with its details, because only the use case knows
the details; a test of the use case finds exactly one entry.
4.3 Visibility¶
visible(spec, p, eff, res) is true if one of the following holds:
- The resource is
instanceandpis logged in (auth), or the action ispublic. eff.level >= readon the path of the resource.- The resource is a space and
phasreadon at least one of its channels (05,space.read). - The resource is an agent and
pis an owner or hasreadon a channel that the agent operates (05,agent.read). - The resource belongs to
pitself (own device, own session, own setting, own privacy request), orpis an owner and the action allowsowner. - DM channel or content in it:
pis a participant. - File with
purpose = 'avatar'or'branding':pis logged in (05, 2.2);gdpr_exportnever throughfile.read. Afilebuilt from an event row without scope is visible only to its audience (audience_principal_id). - The resource is a host and 2.12, item 5 holds; for a terminal session:
pis its person or an owner. - Command (M2-16, 09 section 10.3):
pis its owner or an effective owner, or sees its scope target (readon the channel, the space visible, any principal forinstance; without command facts the scope of the event, for the receive check ofcommand.changed). As an item of the list of a channel C:phasreadon C, C is no dm channel, the scope covers C, the owner is active and C's effective operator, or an agent guest (allow_mentioned_agents, its own level at leastwrite, billing modeorganization), or an app with level at leastwrite.
4.4 Invariants in the transaction¶
authorize checks whether someone may attempt a change. Whether the result is allowed is checked by
authz.VerifyInvariants(tx, change) in the same transaction after the change and before the commit:
- Lock order according to 03, section 6.3 (
permission_versionfirst). - Write the change.
- Determine the affected agents and owners (03, section 6.4).
- Compute I1 (SR1), I6 (SR11, for every host with
llm_credentials = 'personal', M2-40) and SR8 (last owner). - Violated: rollback, response
409with the problem code of the reason (i1_violation,i6_violationorlast_owner, decisioninvariant_violation) and a hint, an audit entry withdecision = 'deny'in a separate transaction. With several violations the answer nameslast_ownerbeforei1_violationbeforei6_violation, andconflictslists the entries of that code (docs/DECISIONS.md D-M2-40-6). - Otherwise:
access.changedper affected principal, audit, commit.
4.5 Archived spaces and channels¶
space.archive and channel.archive are final; 05 has no reverse action. The data stays
for retention and export.
- In an archived channel and in every channel of an archived space, the effective
level of every principal, owners included, is at most
read. Actions whose permission needswriteortriggeron a target in it end withforbiddenand reasonchannel_archived(HTTP403 channel_read_onlywithreason). Reading stays possible for everyone who could read before. - Administrative changes to an archived space or channel (rename, move,
operator, tags) pass
authorize, butchatrejects them with409 conflict. - Archiving is a permission change (column "Permissions" in 05): increase the permission version,
and
access.changedwith an entry inchanged(levelread) for everyone whose level was above it. - The sidebar lists (
listSpaces,listSpaceChannels) do not show archived items; single fetch, history and search still return them to those entitled to them. - I1 considers only non-archived targets (10-backend-policy.md,
section 5.3), because nobody has
triggerthere.
Reason: archiving should freeze a history, not hide it; reading without writing is the behavior that people know from archived channels, and an owner loses nothing by it.
4.6 Resolving mentions¶
A mention token in the content (03-data-model.md, messages,
content format) becomes a mention on send and edit only if the following holds:
| Mentioned | Condition |
|---|---|
| Person | authorize(author, user.read, person) allows, or the person may read the thread of the message (authorize(person, thread.read, thread)) |
| Agent | authorize(author, agent.read, agent) allows |
Otherwise the token stays text. Reason for the second condition for people: agents have no
scope users:read and do not see the directory (05, section 2.6); their mention should still
reach the person who reads the thread (13-push-notifications.md,
section 2.1). The author thereby learns only whether a handle may read the thread they themselves
write in; the directory stays hidden. A person without read permission on the channel whom a person
mentions gets a row in mentions, but never a notification (8.1, row Push).
5. Protection rules that no grant overrides¶
Every guard rule has a number, an enforcement point and a test (section 11).
SR1 Backend policy (I1, I2)¶
- I1, transactional. For every agent with
billing_mode = 'personal', the set of principals with effectivetriggeron any target that the agent operates is exactly{credential_holder_id}(Baseline section 5; check 03, section 6.4). Checked in the transaction of every action from 05, rule 1.6. - Consequence. Owners have
triggereverywhere. Thereforepersonalworks only if thecredential_holderis the only active owner and nobody else hastriggeron the operated targets. If a second owner is created,owner.addfails withi1_violationas long as an agent is onpersonal. Under this consequence, "exactly" and "at most" are the same condition, because the only owner always hastriggerthemselves. - I2, before every invocation.
agent.invokechecks on acceptance and immediately before every delivery: withpersonal, the triggering person is thecredential_holder, otherwisebilling_mode_personal_not_holder, fail closed, audit (05, section 5, item 4; 04, section 15.3).
SR2 Tool class¶
An agent with tool_class = 'host' may only be triggered by:
- an active owner, or
- a person for whom SR3 proves a ready dev environment of their own in which the run is executed.
Otherwise tool_class_owner_only. Checked in agent.invoke on acceptance and before every delivery.
A grant with trigger on a channel with a host operator is not rejected; the preview
(section 9.3) says in plain text that it does not enable triggering. Reason for the check at the
invocation instead of at creation: the readiness of an environment changes without a permission
transaction, and group memberships change often. The invocation is the only place where all facts
are current.
SR3 Dev isolation¶
For channels with a project binding (project_bindings), the following applies in addition to 05,
section 5, item 6:
- A run of a non-owner runs only in a dev environment of this person that is ready
(
dev_environments, 21-dev-isolation.md, section 6). Otherwisedev_environment_not_ready. - The agent session of a thread is bound to the environment in which its first run ran
(
agent_sessions.dev_environment_id, 03). If another person triggers in this thread, that isdev_environment_foreign_thread. Reason: a session reads and writes the files of its environment; a different triggering person would work in the environment of another person. - Owner runs run in the environment of the owner. That is today's single-person operation.
- The invocation token carries
dev_environment_id; the runner starts only in exactly this environment.
As long as there is no ready environment, every dev project can therefore be triggered only by owners, regardless of which grants exist.
SR4 Only humans trigger¶
- Only messages with
origin = 'human'trigger agents; agents, apps, imports, system messages and incoming webhooks never do (05, section 5, item 1). - Agents and apps have at most
writeand onlyuploadas a capability (section 2.1). - Reason: bot loops are ruled out, and the trigger set from I1 stays human.
SR5 Administration only by humans with confirmation¶
- Step-up actions only with a person's session and a fresh confirmation (R-AZ-03).
- Owner actions only by active owners, even if a grant
manageon the instance exists. - Owner status takes effect only in a session. A personal API token of an owner acts with the grants of this person, without owner permissions. Reason: a stolen token without device binding must never open the whole instance (08-identity.md, section 12; 05, section 2.3; R-AZ-14).
- Exception: in the check without a request (before a delivery to an agent, at
run.token.renew, at the run from deferred follow-ups, before handing an app command or an interaction to an app, and for the permissions of the triggering person in SR9), the owner status comes from the current owner list without a session; the permission state computed this way is never the own permission state of the principal of a request, not even of a personal token (05, section 5, "Check without a request"; R-AZ-29).
SR6 Direct messages only for participants¶
No owner status, no grant and no "View as person" opens a DM channel (Ruling
L21). The only way in the product is the GDPR export of a person: gdpr.request.create for another
person, approval and download, each by an owner with step-up and audit; the package
contains only this person's own messages (20-privacy.md,
section 5.2). Other ways, such as an order by an authority, lie outside the product
(R-AZ-02).
SR7 Disabled means no permissions¶
A principal with status disabled or deleted, a revoked session, a revoked
device, a revoked token or an app installation that is not active results in unauthenticated,
before grants are read. A person in state pending and a waiting device result in
forbidden, except for the self-service actions (4.1). Grants of disabled or waiting principals
stay in place but do not take effect.
SR8 Last owner¶
No transaction may remove the last active owner: neither owner.remove nor user.disable
on an owner if afterwards no owner with principals.status = 'active' remains. Code
last_owner (03 owners).
SR9 Acting-for binding (confused deputy)¶
An agent that uses the API within a run attaches the verified invocation token of the run (a header; its name is defined in 06-api.md). Then the following applies to every action that reads content or writes into a channel:
eff(agent in run, target) = eff(agent, target) ∩ eff(triggering person, target)
∩ means: the lower level, the intersection of the capabilities. Exceptions, for which only the
permissions of the agent apply: replies in the thread of the run, patches to its own run,
run.finish, interaction.respond, agent_session.report. The token is checked on every request
(signature, aud, run active); the permissions of the person are computed fresh, not read from
the token. A revocation therefore also takes effect immediately on tool calls of a running agent.
Reason: without this rule, a person with trigger on channel A can ask an agent that may also
read channel B to summarize B. The agent is then a "confused deputy". The
SDKs (09-agents.md) automatically bind the token to every API call during a run.
What the runtime knows outside the server API (its own memory, earlier sessions) is not
covered by the rule; for that there is the read horizon hint (section 6) and the threat
T-AI-02 in the threat model.
The header is Invocation-Token with the compact JWS of EventEnvelope.invocation_token of the
run (06-api.md, section 4 point 8). The binding check on every request: the
signature, typ, kid and iss of the token, aud equal to the authenticated agent, then the
run of the claim run_id, which must exist, belong to this agent, be triggered by sub (for the
tokens of trigger kind message and command; an interaction or follow-up token names the person
who acted, and the binding always takes the person from the run, 09-agents.md
section 4.5) and lie in the claimed thread; a token without run_id (autocomplete, a command without an active run) is
invalid, because SR9 binds a run. An active run (queued, running, waiting_input) binds; an
ended one answers invocation_token_invalid with reason: run_inactive, every other failure
reason: invalid, also for the header sent by a principal that is not an agent. Expiry and the
currency of the jti are not checked: a run outlives agent.invocation_token_ttl, the binding
always intersects with the run's triggering person, and the token never adds a right, so a leaked or older token gives its
holder nothing the agent token alone would not give (docs/DECISIONS.md D-M2-14-3). The exceptions
above hold only for the own thread and run: the resolved thread of the resource is the run's
thread, and for a resource of kind run it is the run itself, so run.finish of an earlier run in
the same thread is bound; run.start and run.token.renew are not bound at all, every other action
is (section 4.2). The rights of the triggering person are computed at the request like the re-check
of R-AZ-29: a principal of kind user with the person's ID and no session, device, token,
view_as or acting_for, liveness from the principal status only (a disabled, deleted or
pending person has no rights, SR7, so every bound action ends with acting_for_insufficient),
owner status from the current owner list without a session. What SR9 does not cover: the server
cannot tell a request inside a run that omits the header from one outside a run (AZ-M45, hint H1
of section 6; the SDKs and reference adapters always send it); knowledge a runtime keeps across
runs is outside the API (section 6, T-AI-01); an owner who triggered with a personal API token is
intersected as owner (R-AZ-29).
SR10 Terminal only on own host¶
A person who is not an active owner gets terminal.open and terminal.attach only on a
host that
- is assigned to them (
hosts.assigned_user_id = P), or - is their own ready dev environment (
hosts.dev_environment_idwithdev_environments.user_id = Pand readiness according to 21-dev-isolation.md, section 6.2; milestone "Multi-user dev environment").
Otherwise terminal_not_host_assignee. On a server host (hosts.declared_server_host or
hosts.reported_is_server_host) only owners (terminal_server_host_owner_only); the host service
additionally checks this against the claim own of the terminal session token. Checked on every
open and attach and on every change of assigned_user_id, dev_environment_id or the owner status:
if the condition no longer holds, the sessions end (access_revoked). If updateHost changes or
clears the assignee, the same transaction ends the sessions of the previous assignee on the host,
unless they are an active owner; sessions of owners and of the new assignee stay (M2-40). Only the
previous assignee can lose SR10 through this change; SR10 refused every other non-owner before
(docs/DECISIONS.md D-M2-40-10). Reason:
21-dev-isolation.md and ADR 0046 reject the OS user as the sole boundary
between people; a shared home directory shares secrets and LLM logins (Ruling T1).
SR11 Terminal and LLM login (I6)¶
If hosts.llm_credentials = 'personal', terminal.open and terminal.attach allow only
hosts.credential_holder_id, not even another owner (i6_holder_only). The transaction rejects
grants on such hosts to subjects other than the holder, including to groups
(i6_violation, 03 section 6.8). Distinction from SR1: I1 has no owner exception, I6 has one,
because a terminal is access to one specific host and not an inherited level
(24-terminal.md, section 7.3; ADR 0068).
SR12 Own session user (Ruling T12)¶
terminal.open and terminal.attach allow only a person P who has a valid assignment to a
session user on the host (host_session_users with user_id = P and
revoked_at IS NULL, whose os_user is in hosts.reported_session_users), owners included;
otherwise terminal_no_session_user. Reason for this error: otherwise only the host service would
reject a name that the host does not know, as tampering (4104, 24 section 5.3). A session always runs
as the session user of its person. A session user belongs to at most one person, also after the
end of the assignment (03, host_session_users); the transaction of host.manage rejects an
assignment that would violate this (409 conflict with reason: session_user_taken). Every assignment
and every end of one raises the permission version; the end terminates the person's sessions on the host
in the same transaction (access_revoked), and so does a report of the host that no longer lists
that name. SR12 replaces neither SR10 nor SR11 and applies in addition.
Reason: this way the credentials of a session user belong to exactly one person; a shared
session user would make every subscription login in it usable for all terminal users of the host
(24-terminal.md, sections 2.2 and 7.4; ADR 0068).
6. Read horizon of agents¶
The read horizon H(A) of an agent is the set of channels that A may read (grants plus
operator rule). The trigger set T(A) is all people with effective trigger on a target that
A operates, for whom no guard rule blocks triggering.
Hint H1: for every person P in T(A) and every channel K in H(A) that P may not read,
P can learn content from K through A if the runtime of A keeps knowledge across
runs. SR9 prevents this only for read access through the server API.
H1 is a hint, not a block. It appears:
- in the preview of every permission change (section 9.3),
- in "Who has access?" as the source "indirectly via agent A" (section 9.1),
- in the "Permission hints" list of the admin console.
Reason for a hint instead of a block: a hard rule would also have to apply on channel.create (a new
channel in a space with an agent grant enlarges H(A)) and would make creating channels
fail unexpectedly. Only the instance operator knows whether a runtime keeps knowledge. The
recommended configuration is one agent per audience: the people in T(A) may read at least everything
that A reads. Then H1 is empty.
7. Filtering in SQL: permission cache with version counter¶
7.1 Decision¶
Lists, counters, search, suggestions and catch-up filter in the database query with the set of
visible channels from a permission cache in authz. There is no materialized view of effective
permissions in the database (R-DM-19). The rejected alternative and the reasons are in ADR 0018.
In short: a materialized view would be a second truth that would still have to check expiry times in every
query (valid_until > now()), would write many rows on every group change and would silently return
wrong results on a bug in its maintenance. A cache that is built from
the same function as authorize cannot diverge without the differential test
(AZ-P8) finding it.
7.2 Permission state (snapshot)¶
RightsSnapshot {
principal_id, permission_version, computed_at, valid_until
owner: bool
instance_level, instance_caps # from grants on the instance
level_by_space: map[space_id]level # from grants on spaces
caps_by_space: map[space_id]set
level_by_channel: map[channel_id]level # from grants on channels; DM channels the principal takes part in
caps_by_channel: map[channel_id]set
instance_wide: bool # owner or read on the instance
fully_visible_space_ids: sorted list # spaces with read on the space
visible_channel_ids: sorted list # channels with read on the channel, DMs the principal takes part in
visible_space_ids: sorted list # spaces with at least one visible channel
provenance (only for access.explain, not in the cache)
}
Construction:
- Valid grants of the principal, for people also those of their valid groups, all with
the time
tfrom section 2.5, per target (instance, space, channel): highest level, union of the capabilities. - Owner:
triggerand all capabilities on the instance (never applies to DM channels). - DM channels the principal takes part in, with
writeandupload. valid_until= smallestexpires_atof all grants and memberships used (or infinity).
The snapshot stores levels per target, not expanded per channel. authorize forms the effective level on a
resource only at call time, from the path that resolve returns (channel, space,
instance): highest level along the path, plus the operator rule from the resolved effective
operator (section 2.9), the limit for agents and apps (2.1) and the boundary of archived
targets (4.5). In the same way Scope forms the lists for queries per request (7.4). Reason: a new channel
or space does not change the permission version (05, channel.create and space.create have "Permissions"
no); with expanded channel lists in the cache it would stay invisible until the next permission change to everyone
with a grant on the space or instance. Catch-up uses the same names directly in
its query, because event_log carries the column space_id (04, section 7.2).
Limit: the permission snapshot knows targets, not threads. The operator rule at thread level
(section 2.9, item 2: threads.operator_agent_id) therefore applies in authorize for the single
thread, but not in list filters (7.4). An agent that operates a thread in an
otherwise unreadable channel does not see it in lists. This includes too little and
reveals nothing. How agents read threads is decided by milestone M2 (agent platform).
7.3 Validity of the cache¶
A cache entry is valid as long as both hold:
permission_versionof the entry equals the current permission version, andrequest_time < valid_until.
Every REST request reads the current permission version in its read transaction
(SELECT value FROM sequence_counters WHERE name = 'permission_version', one row through the
primary key). Gateway processes use the version from the event log in log order
(04, section 8.1). So every request after the commit of a permission change sees the new
state, also in another process. There is no read without the current version.
The version counter is global. Every permission change invalidates all entries; only those who make a request afterwards are recomputed. Permission changes are rare, requests frequent; a counter per principal would make the case "group with many members" expensive and is therefore not needed.
7.4 Query pattern¶
-- Message search, $1 = visible channels from Scope (formed per request, 7.2),
-- $2 = search expression, built by the search module (14-search.md)
SELECT m.id, m.thread_id, m.channel_id, ts_rank(m.search_vector, $2::tsquery) AS rank
FROM messages m
WHERE m.channel_id = ANY($1::uuid[])
AND m.deleted_at IS NULL
AND m.search_vector @@ $2::tsquery
ORDER BY rank DESC, m.id DESC
LIMIT $3;
-- Unread counter per channel, $1 = requested channels, intersected with Scope
SELECT t.channel_id,
sum(t.last_message_seq - greatest(coalesce(r.last_read_seq, 0), t.unread_floor_seq)) AS unread
FROM threads t
LEFT JOIN read_states r ON r.thread_id = t.id AND r.user_id = $2
WHERE t.channel_id = ANY($1::uuid[])
GROUP BY t.channel_id;
Rules:
- Every query over content, threads, channels, spaces, runs, files or mentions has the
predicate
channel_id = ANY($visible)(orspace_id = ANY($visible_spaces)). The lists are formed byAuthorizer.Scopeper request from the permission snapshot and the current structure (StructureSource): channels with their own grant and DM channels the principal takes part in, all channels of the spaces infully_visible_space_ids, withinstance_wideall channels except DMs, for agents the channels they operate under the operator rule. Withview_asoracting_for(SR9)Scopeintersects the lists with those of that person, asauthorizeintersects the permissions. New channels and spaces thus appear without a new permission version. A lint in the repository layer rejects reading sqlc queries on these tables without this predicate, unless they are on a reviewed exception list (resolution forauthorize, loading a resource that has already been checked, jobs with system permission such as retention, log tail). The lint istools/lint/sql_visibility.py(gatesql-visibility, AZ-P12); the reviewed exception list with a category and a reason per query istools/lint/sql_visibility.yaml. The list of cost estimates per run for the reconciliation (cost.reconcile.read) stays without this predicate: it checks the action once against the instance, without a check per entry (rule 3), reads onlycost_entriesand names neither channel nor thread nor content (05, rule 1.2 and R-AZ-28). Writing queries only hit rows of a resource thatauthorizehas checked before (for example the copies ofchannel_idwhen moving); the lint does not check them. Thread-scoped tables withoutchannel_id(runs,run_steps; a run follows its thread when the thread moves, 03threads) follow one of two forms instead (D-M2-05-9): (a) the query reads the rows of exactly one thread or run whose visibility was checked first (getRun,listRunSteps,listThreadRuns, the active run of a thread at the acceptance; exception categoryafter_authorize, the load beforeauthorizeingetRunauthorize_resolution), or (b) for the active-run lists (listActiveRuns,Snapshot.active_runs) it reads the active runs, resolves their threads through the port ofchat, keeps only threads whose channel is inScope.ChannelIDsand runsauthorizeper row (categorythread_scope). The gauge query ofbothy.run.activehas no principal (system_job). - Totals (
total), facets and "more results" count only within this predicate. - After the SQL filter,
authorizealso runs for every result with the read action (for examplemessage.read). The SQL filter is the performance,authorizethe truth. A difference between the two discards the element, writes a log without IDs and content and incrementsbothy.authz.errorswithbothy.reason = filter_mismatch; the alertBothyAuthzErrordepends on it (signals.md). - Suggestions (mentions, commands, channel quick switcher) follow the same rules: a person whom the asker may not see does not appear, and the count reveals nothing.
- Large instances: if the list gets so long that the query planner switches to a bad plan, the remedy is a temporary
table from the same snapshot in the same transaction, never a permanent permission table.
The load test
perf/catchup(perf.catchup_page_p99, 02-architecture.md, section 17) also runs with a large set of visible channels and pins the limit. - The examples above show the result, not the place of the query: if the tables are in
different modules (here
threadsinchat,read_statesinsync), the code assembles the parts through ports (02, section 4.1); no module reads foreign tables.
8. Delivery filter per recipient and immediate effect¶
8.1 Delivery filter¶
| Path | Check per recipient | Source |
|---|---|---|
Durable events (gateway, GET /api/v1/events) |
authorize(recipient, receive_action, resource); personal events only to audience_principal_id. An app as the audience of a personal event (command.invoked, interaction.created, and the ephemeral command.autocomplete_requested) passes the receive action agent.events.consume in filter mode with an active installation and events:subscribe (another principal's event not_self, an installation that is not active app_not_installed, a missing events:subscribe scope_missing); in request mode, the consumer operations of agents, an app stays subject_kind_not_allowed (M2-25) |
05, section 6; 04, section 7; 03 event_log.receive_action |
Ephemeral events (typing, run.updated, presence) |
receive action and focus | 05, section 6; 04, sections 11, 12 |
| Agent consumers | like people, according to their own grants, plus personal events to the agent; before agent.invocation, agent.followup, interaction.created, command.invoked additionally agent.invoke for the triggering person; the header Invocation-Token (SR9) does not narrow these decisions (delivery mode, 4.2) |
04, section 15 |
| Push | push.deliver (read permission on the thread), notification preference, quiet hours, not the author; mentions without read permission produce nothing |
05; 13-push-notifications.md |
| Outgoing webhooks | webhook_out.deliver: receive action of the event for the app according to grants and scope, subscription to the type; personal events of the app by the receive rule for apps of the row "Durable events" |
05 |
| File fetch | file.read on every fetch through the server, also with a file ticket; no permanent links to clients |
12-files.md, ADR 0048 |
| Search, counters, lists | SQL filter plus authorize per element (section 7.4) |
this chapter |
8.2 Immediate effect on revocation¶
A revocation (grant revoked, membership removed, owner removed, principal disabled, device or session revoked, operator changed) takes effect in this order:
- Commit. The transaction raises
permission_versionas its first step (03, section 6.3) and writesaccess.changedper affected principal as well as the audit entry. - REST. Every request after the commit reads the new version and recomputes the snapshot (section 7.3). There is no time window with the old state.
- Open connections. Every gateway process re-evaluates the connections of the affected principal
when it reaches
access.changedin the log: focus on removed targets ends, buffered events of these targets are discarded, the event goes out immediately (04, section 8.3). On a session, device or principal revocation it closes with4012(04, section 8.5). - Active runs. If a person loses
triggeron the thread of an active run that they triggered, the same transaction writesrun.cancel_requestedwith reasonaccess_revoked(R-SY-11). Afteragent.cancel_gracethe server sets the run tocancelleditself. Tool calls of the agent already fail before that because of SR9. - Queued invocations. I2 before every delivery makes the run
failed(04, section 15.3). - Components. An interaction checks
triggeron click; open cards are not rewritten. - Terminal sessions. If a person loses the effective
terminalon a host, if SR10 or SR11 applies or if their assignment ends under SR12, the same transaction ends their sessions there (access_revokedori6_violationrespectively); the server closes the connections and instructs the host service to end the PTY. Disabling the person, revoking a device or login session, and logout end the sessions that depend on them (24-terminal.md, section 8.3). - Tokens. Access tokens stay formally valid until they expire, but
authorizechecks session and permissions on every request. Invocation tokens expire afteragent.invocation_token_ttland are checked again atrun.start.
8.3 Expiry by time¶
An expiry is not a write. The decision is still immediately correct, because every call
checks the time and valid_until invalidates the cache. So that clients clear their cache and runs
end, the job authz.expiry_sweep runs every authz.expiry_sweep_interval. It finds grants and
memberships with expires_at up to now whose expiry has not been reported yet
(expiry_reported_at IS NULL, 03), at most authz.expiry_sweep_batch_max rows per transaction,
and writes per batch in one transaction: raise the permission version, set expiry_reported_at,
access.changed, grant.changed with change = expired or group.changed with
change = member_expired respectively, run.cancel_requested for affected runs, an audit entry with
system action grant.expire or group.member.expire respectively (05, R-AZ-15). Because the selection depends on the
stored flag and not on the time of the last run, every expiry is reported exactly once,
also after an outage of the worker. The largest delay in clearing the client cache
is authz.expiry_sweep_interval; the delivery of new content ends immediately.
9. Requirements for the admin interface¶
9.1 "Who has access?" (access.explain)¶
For a space or channel the server returns per principal: effective level, capabilities and the source as a chain. Source kinds:
| Source | Example in plain text |
|---|---|
| direct | "Grant for Alex on space Support, until 12-31, reason: stand-in" |
| group | "via group Support-Team (member until 11-30)" |
| inherited | "inherited from space Support" |
| owner | "Owner" |
| operator | "Operator of this channel" (agent) |
| DM | "Participant" |
| indirectly via agent | "can learn content of this channel through agent Lotse" (hint H1) |
| blocked by guard rule | "trigger, but triggering blocked: dev environment not ready (SR3)" |
In the API the source kinds are called owner, direct, group, inherited, operator,
dm_participant, via_agent and guard_blocked (schema AccessSource, with agent, rule,
expiry and reason of the grant); an entry only from via_agent has the level none.
Disabled principals appear struck through (inactive) with the level that their grants
would give. The view needs owner or manage (05).
9.2 "View as person" (access.view_as)¶
- Only owners, with step-up, audit at start and end.
- The server sets
view_asin the principal context for at mostauthz.view_as_max_duration. - Effective permissions are the intersection of owner and person. DM channels of the person stay
invisible (SR6). All writing actions end with
view_as_read_only; after expiry every decision ends withstep_up_required. - The console shows a banner with name and remaining time throughout.
- Purpose: check what a person sees and can trigger, without knowing their credentials.
- Stored view (M6-01, R-DM-49, R-AZ-32):
viewAsUsercalled from a console session stores the target person and the start on the session (auth_sessions.view_as_user_id,view_as_started_at) in the transaction of its audit entry (target_user_id,started_at,expires_at). Every later request of that session carriesview_asuntilendViewAs(access.view_as.end, auditended_reason = ended) or untilauthz.view_as_max_durationhas passed; the first request after that ends the view (auditended_reason = expired) and answers401 step_up_required. Starting a new view while one is stored is a write and is denied; the console ends the current view first. Called from any other session,viewAsUserstays a snapshot that lives only in that request.getConsoleSessionshows the stored view with its end.
9.3 Plain-text preview¶
Every action with column "Permissions" = yes has a preview without effect: the action
access.preview (05; permission like the target action, without step-up, without audit, rl.admin). For
grants the route is POST /api/v1/grants/preview; previews of further actions come with
their routes. The server executes
the change in a transaction, computes differences and invariants and rolls back. The
response contains:
- per affected principal: added, removed and changed levels and capabilities,
- the result of I1 and SR8 (whether the change would be rejected, with the hint "switch to organization first"),
- hints: SR2 and SR3 block triggering, SR10 or SR12 block a terminal
(
terminal_not_effective), H1 read horizon, a grant to a group reaches n people, expiry date missing for persons outside of groups (API:hints), - a text from templates (no model generates it), in the language of the caller (primary subtag of
users.locale,deoren, otherwisede, as in 01 "Languages"; API:text,text_locale). Dates are in the time zone of the caller (users.time_zone). The templates are a catalogue per language in the code ofauthz; a golden test holds the example below byte for byte.
For grants, permission of the target action means: read permission on the target (otherwise 404) and owner or
manage on it (otherwise 403). The limits of delegation (2.6: own level, own capabilities,
agents and apps only by owners, instance only by owners) let the preview succeed: 200 with
allowed = false and denial_reason with the reason from 4.1. Reason: the preview should show
why a grant would fail.
Example (locale de):
Sam erhält bis 31.12.2026 die Stufe "Auslösen" im Kanal Marketing/Kampagnen.
Sam kann damit: Nachrichten lesen und schreiben, Threads anlegen, Agent Texter auslösen.
Dateien hochladen: nein. Freigabe-Karten bestätigen: nein.
Hinweis: Texter liest auch Marketing/Intern. Sam darf Marketing/Intern nicht lesen, kann aber über Texter Inhalte daraus erfahren.
A grant on a host (M2-40) shows as its effect, per person reached, the host with before_level and
after_level none and capabilities_added: [terminal]; a violation of I6 is listed in
invariant_violations with allowed = false. terminal_not_effective names principal_id for a
person whom SR10 or SR12 would deny on the host after the change, and group_id and count for a
group (members who would be denied; without such members no hint); with an I6 violation of the same
host it is left out. The text has no line on capabilities and levels in the tree: summary, denial,
I6, terminal_not_effective (for a person with the reason of the first violated rule: server host,
assignee, session user), group, missing expiry date (docs/DECISIONS.md D-M2-40-11). Example (Sam is
not the assignee of R1):
Sam erhält das Zusatzrecht "Terminal" auf dem Rechner R1.
Hinweis: Die Freigabe wirkt für Sam nicht. Auf R1 öffnen nur Owner und die Person, der der Rechner zugewiesen ist, ein Terminal.
Hinweis: Die Freigabe für Sam hat kein Ablaufdatum.
The console shows the preview before every confirmation; the step-up follows afterwards.
Preview with resolve_i1 (guided flow, 10-backend-policy.md
section 11; M2-21): the preview is a dry run of the whole request. After access.preview for the
grant target, every listed agent needs access.preview with the target action
agent.billing_mode.set in ascending id (owner, never step-up, never audit; a visible agent and a
caller who is not an owner give 403 with owner_only, an unknown or invisible agent the 404
of an unknown id). In the rolled-back transaction the agents are switched to organization before
the grant is applied, so allowed, effects, invariant_violations and hints describe both
parts. text starts with one line per agent the request switches from personal ("First ... is
switched to Organization; the organization key then bears the costs.") and, when organization is
not in the agent's billing_modes_supported, the line "The adapter of ... does not report an
organization key yet. Invocations fail until it is switched." (German catalogue accordingly;
docs/decisions.d/M2-21.md D-M2-21-8).
9.4 Expiry reminder¶
The job authz.expiry_reminder runs with authz.expiry_sweep_interval and finds grants and
memberships that expire within authz.expiry_reminder_lead and have not been reminded yet
(column expiry_reminded_at on grants and group_memberships, 03), at most
authz.expiry_sweep_batch_max per transaction. It writes the personal event
access.expiring (05) to active recipients, without duplicates: for grants to a person, agent
or app, to the subject and to granted_by; for grants to a group, to all active
owners and to granted_by (a group is not a principal that receives personal events);
for memberships, to the member and to added_by. For grants on the instance
target is missing, because a target can only be a space or a channel. The console shows the list "Expiring soon" with
"Extend" (revocation plus a new grant with a new reason). There are no emails (Baseline
section 4).
The list "Expiring soon" is the operation listExpiringAccess (GET /api/v1/access/expiring,
action access.hints.read, R-AZ-34). It holds grants that are not revoked and memberships that
are not removed (of groups that are not deleted) whose expires_at lies in
(now, now + authz.expiry_reminder_lead], whether reminded or not, and for owners on the
instance the API tokens of agents and apps that are not revoked and expire in the same window;
personal tokens and token values never appear. The entries are ordered by expiry, then grant
before membership before token, then ID. With space_id a person with manage on the space
sees only grants on the space and its channels and the memberships of groups that hold such a
grant in effect, and no tokens. "Extend" opens the grant wizard with subject, target, level and
capabilities of the old grant, an empty reason and a new expiry; confirming creates the new
grant first and then revokes the old one, so the access never lapses in between. A token is
renewed by creating a new one (its secret shown once) and revoking the old one.
9.5 Further obligations¶
- Revoking takes effect immediately and says so in the confirmation.
- Every grant and every revocation needs a reason (03,
grants.reasonandgrants.revoke_reason); deleting a group revokes its grants with the reasongroup_deleted(M1-48). -
List "Permission hints": H1, grants to disabled principals,
triggerwithout possible triggering (SR2, SR3), agents withpersonaland their trigger set. It is the operationlistPermissionHints(GET /api/v1/access/hints, actionaccess.hints.read, R-AZ-34), computed per request with the snapshot code ofauthorize, never a second rule set, and it never names content:h1_read_horizon: for every agent A, every person P in T(A) and every channel K in H(A) that P cannot read (section 6), the same function as the sourcevia_agentof section 9.1.grant_to_disabled: a grant in effect whose subject person, agent or app isdisabledordeleted(SR7); a pending person's grants take effect and do not count.trigger_blocked: a grant in effect with leveltriggerto an active person or a group that reaches at least one channel with an operator, where on every such channel SR2 (an operator of tool classhost) or SR3 (a project binding) keeps the subject from triggering because it is no owner and has no ready dev environment of its own there; the hint names the blocking rules.agent_personal: every active agent withbilling_mode = personal, its credential holder and its trigger set of SR1; I1 asks for exactly the holder.
The hints come in this order of kinds, within a kind by agent, person and channel (H1), by grant ID or by agent ID, and a page carries the names of the principals, groups, spaces and channels it names. Without
space_idthe list covers the instance and needs the owner role; withspace_idthe space andmanagethere (a space without read gives404, with read but withoutmanage403): H1 over the channels of the space for every agent, the grants on the space and its channels, and the personal agents that serve a target in the space. -
Filterable log from the audit log (
audit.read), ADR 0021.
10. Examples¶
Starting point for all examples (fictional): spaces Support, Marketing, Engineering, Research. One
owner. Agents: Lotse (operator of Support, organization, chat), Texter (operator of
Marketing, organization, scoped), Runner (operator of the forum Engineering/Projekt-A with
project binding, organization, host), Assistent (operator of Research, personal,
credential_holder = owner, scoped).
10.1 Support only for Alex¶
| Grant | Subject | Target | Level | Capabilities | Expiry | Reason |
|---|---|---|---|---|---|---|
| G-1 | Alex | space Support | trigger |
upload, approve_actions |
none | Support lead |
Result: Alex sees only Support, can trigger Lotse and confirm its approval cards.
Marketing, Engineering and Research are 404 for Alex; search and counters show nothing there.
Lotse is organization: that is required as soon as Alex has trigger (I1).
10.2 Marketing only for Sam¶
| Grant | Subject | Target | Level | Capabilities | Expiry | Reason |
|---|---|---|---|---|---|---|
| G-2 | Sam | space Marketing | trigger |
upload |
none | Marketing |
Result: Sam triggers Texter. Sam cannot confirm approval cards of Texter with approve_actions;
that stays with the owner. If Texter also reads a channel that Sam may not read,
the preview shows hint H1.
10.3 A dev project for Robin with expiry¶
| Grant | Subject | Target | Level | Capabilities | Expiry | Reason |
|---|---|---|---|---|---|---|
| G-3 | Robin | channel Engineering/Projekt-A | trigger |
upload |
2026-12-31 | project work |
Result until dev isolation exists: Robin reads and writes in the forum, but every triggering ends with
dev_environment_not_ready (SR3) or tool_class_owner_only (SR2). The preview says so before
the grant is created. As soon as a ready environment is registered for Robin, Robin's threads run there.
Robin can read threads of the owner but not trigger them (dev_environment_foreign_thread). From
2026-12-31 the channel is 404 for Robin, without a job having to run.
10.4 Owner has everything¶
The owner sees all spaces and channels and triggers all agents, also Assistent (personal, the owner is
the credential_holder). The owner does not see DMs between Alex and Sam. If the owner gave Alex trigger on
Research, the server would reject it with i1_violation: "switch to organization first".
11. Tests¶
11.1 Test matrix¶
World of the matrix: as in section 10, plus group Leser (member Sam, membership with expiry) with
grant G-4 (read on Support/Allgemein), grant G-5 (Lotse, write, upload, space
Support), grant G-6 (app Tracker, write, channel Support/Eskalation, scope only
messages:write), channel Engineering/Werkzeuge (text, operator Runner, without project binding), one
DM between Alex and Sam. The clock is controllable in tests.
| ID | Principal | Target | Action | Condition | Expected |
|---|---|---|---|---|---|
| AZ-M01 | Owner | Research/Notizen | message.read |
allow |
|
| AZ-M02 | Owner | DM Alex-Sam | message.read |
not_found (SR6) |
|
| AZ-M03 | Alex | Support/Allgemein | message.create |
G-1 on space | allow (inherited) |
| AZ-M04 | Alex | Support/Allgemein | agent.invoke (Lotse) |
allow |
|
| AZ-M05 | Alex | Marketing/Kampagnen | message.read |
not_found |
|
| AZ-M06 | Alex | instance | search.query "campaign" |
3 hits only in Marketing | 0 hits, no counters |
| AZ-M07 | Sam | Support/Allgemein | message.read |
membership in Leser valid | allow (group) |
| AZ-M08 | Sam | Support/Allgemein | message.create |
only G-4 | forbidden level_insufficient |
| AZ-M09 | Sam | Support/Allgemein | message.read |
clock = expiry of the membership | not_found (boundary, without a job) |
| AZ-M10 | Sam | Support/Allgemein | message.read |
membership removed | not_found |
| AZ-M11 | Alex | Support/Eskalation | agent.invoke |
G-1 trigger on space and additionally read on channel |
allow (highest applies, overlapping) |
| AZ-M12 | Alex | Support/Eskalation | message.create |
G-1 revoked, read on channel remains |
forbidden level_insufficient |
| AZ-M13 | Alex | Support/Allgemein | message.read |
G-1 revoked | not_found |
| AZ-M14 | Robin | Engineering/Projekt-A | message.create |
G-3 valid | allow |
| AZ-M15 | Robin | Engineering/Projekt-A | agent.invoke |
no dev environment | forbidden dev_environment_not_ready |
| AZ-M16 | Robin | Engineering/Projekt-A | agent.invoke |
own environment ready | allow, token with dev_environment_id |
| AZ-M17 | Robin | thread of the owner in Projekt-A | agent.invoke |
own environment ready | forbidden dev_environment_foreign_thread |
| AZ-M18 | Robin | Engineering/Projekt-A | thread.read |
clock after 2026-12-31 | not_found |
| AZ-M19 | Owner | Engineering/Projekt-A | agent.invoke |
allow |
|
| AZ-M20 | Alex | Engineering/Werkzeuge | agent.invoke (Runner, host) |
trigger grant on channel |
forbidden tool_class_owner_only |
| AZ-M21 | Owner | Research/Notizen | grant.create Sam trigger |
Assistent personal |
invariant_violation i1_violation |
| AZ-M22 | Owner | Assistent | agent.billing_mode.set to personal |
Alex has trigger on an operated channel |
invariant_violation i1_violation |
| AZ-M23 | Owner | Alex | owner.add |
Assistent personal |
invariant_violation i1_violation |
| AZ-M24 | Owner | Support/Allgemein | channel.operator.set Assistent |
Alex has trigger |
invariant_violation i1_violation |
| AZ-M25 | Owner | group with trigger on Research |
group.member.add Sam |
Assistent personal |
invariant_violation i1_violation |
| AZ-M26 | Owner | the owner itself | user.disable |
only owner | invariant_violation last_owner |
| AZ-M27 | Lotse | Support/Allgemein | message.create |
G-5 | allow |
| AZ-M28 | Lotse | Marketing/Kampagnen | message.create |
agent in a foreign space | not_found |
| AZ-M29 | Texter | Marketing/Kampagnen | message.create |
no grant, operator | allow (operator rule) |
| AZ-M30 | Lotse | Support/Allgemein | message mentions Texter | origin = agent |
no run (SR4) |
| AZ-M31 | Owner | Lotse | grant.create trigger |
subject is an agent | forbidden subject_kind_not_allowed |
| AZ-M32 | Tracker | Support/Eskalation | message.create |
G-6, scope messages:write |
allow |
| AZ-M33 | Tracker | Support/Eskalation | message.read |
scope messages:read missing |
forbidden scope_missing |
| AZ-M34 | Tracker | Support/Allgemein | message.create |
no grant | not_found |
| AZ-M35 | Sam with trigger + manage on Marketing, personal token |
Marketing | grant.create Robin read |
token without a session | forbidden step_up_not_possible |
| AZ-M36 | Owner | Marketing | grant.create |
step-up older than auth.step_up_max_age |
step_up_required |
| AZ-M37 | Alex with write + manage on Support |
Support/Allgemein | grant.create Sam trigger |
forbidden delegation_exceeds_own_level |
|
| AZ-M38 | Alex with trigger + manage on Support |
Support/Allgemein | grant.create Sam read + export |
Alex without export |
forbidden delegation_exceeds_own_capabilities |
| AZ-M39 | Alex with write + manage on Support |
grant of the owner for Sam (trigger) |
grant.revoke |
forbidden delegation_exceeds_own_level |
|
| AZ-M40 | Alex | any | any | Alex disabled | unauthenticated principal_inactive |
| AZ-M41 | Alex | any | any | session revoked, token not yet expired | unauthenticated session_revoked |
| AZ-M42 | Alex | card with restricted_to = run_starter, run by the owner |
interaction.create |
forbidden component_restricted |
|
| AZ-M43 | Sam | card with approve_actions in Marketing |
interaction.create |
Sam without approve_actions |
forbidden capability_missing |
| AZ-M44 | Lotse in a run of Alex | Engineering/Projekt-A | message.read |
Lotse additionally has read on Engineering |
not_found acting_for_insufficient (SR9) |
| AZ-M45 | Lotse without a run context | Engineering/Projekt-A | message.read |
as in M44 | allow, hint H1 in the console |
| AZ-M46 | Owner as Alex | Marketing/Kampagnen | message.read |
"View as person" | not_found |
| AZ-M47 | Owner as Alex | Support/Allgemein | message.create |
"View as person" | forbidden view_as_read_only |
| AZ-M48 | Alex | Marketing message with a mention of Alex | push.deliver |
no push, mention stored silently | |
| AZ-M49 | Tracker | event from Support/Allgemein | webhook_out.deliver |
subscription to message.created |
not delivered |
| AZ-M50 | Sam | Support/Allgemein | message.read |
group Leser deleted | not_found |
| AZ-M51 | Alex | Support/Allgemein | agent.invoke |
revocation between acceptance and delivery | run failed access_revoked, no delivery (I2) |
| AZ-M52 | Alex | Marketing/Kampagnen | GET message with a foreign ID |
same as a random ID (status, body, headers) | |
| AZ-M53 | incoming webhook of Tracker | Support/Eskalation | webhook_in.post with a mention of Lotse |
channel with free_response |
message origin = app, no run (SR4) |
| AZ-M54 | Alex | host R1 (assignee Alex, organization) |
terminal.open |
grant terminal to Alex, fresh step-up |
allow |
| AZ-M55 | Sam | host R1 | terminal.open |
grant terminal to Sam |
forbidden terminal_not_host_assignee (SR10) |
| AZ-M56 | Owner | host R2 (personal, holder of the login Robin, Robin is a second owner) |
terminal.open |
forbidden i6_holder_only (SR11) |
|
| AZ-M57 | Owner | host R2 | grant.create group Leser terminal |
invariant_violation i6_violation |
|
| AZ-M58 | Alex | host R1 | terminal.open |
step-up already used for another opening | step_up_required terminal_step_up_not_fresh |
| AZ-M59 | Lotse | host R1 | terminal.open |
agent token | forbidden subject_kind_not_allowed |
| AZ-M60 | Alex | host R3 (without assignee) | terminal.open |
grant on the instance with trigger and all capabilities, no grant on R3 |
not_found (host not visible) |
| AZ-M61 | Owner | host R1 | terminal.open |
fresh step-up, no valid assignment of the owner to a session user on R1 | forbidden terminal_no_session_user (SR12) |
11.2 Required tests¶
| ID | Test | Fails if |
|---|---|---|
| AZ-P1 | Route test: counts all registered HTTP routes | a route has no known x-authz-action (05, rule 3) |
| AZ-P2 | Op and event test | a WebSocket op or an event type is registered without an entry in 05, section 6 or 7 respectively |
| AZ-P3 | Catalogue closed | an unknown action does not end with 500 and an alert |
| AZ-P4 | 404 equality | the response for a foreign ID and for an unknown ID differs in status, body or headers, or the median response time deviates by more than authz.not_found_timing_tolerance. Routes whose resource the requester may always see (for example user.read for people) have no foreign invisible instance and are listed with a reason as "not applicable" in the test's list |
| AZ-P5 | Counters and search reveal no foreign channels | in randomly generated worlds (property test) unread counters, mention counters, search hits, total, facets, suggestions, "Running now" or the snapshot take into account a channel without read permission |
| AZ-P6 | Adapter without a valid token does nothing | the reference adapter or an SDK adapter starts the runtime with a missing token, a wrong signature, a wrong aud, an expired token, a repeated jti, a differing billing_mode or a differing tool_class (conformance test of the SDKs, 09-agents.md) |
| AZ-P7 | I1 for every kind of change | for one of the actions from 05, rule 1.6, a change that violates I1 is committed; one case per action |
| AZ-P8 | Differential test of the cache against a reference | authorize with the cache decides differently in random worlds than a slow reference without a cache |
| AZ-P9 | Immediate revocation | after the commit of a revocation in process A, a request to process B is still allowed. Process means a separate server instance with its own pool, log tail, permission cache and hub on the same database; two such instances in one test binary suffice, because they share no memory |
| AZ-P10 | Expiry without a job | with authz.expiry_sweep switched off, an expired grant still takes effect |
| AZ-P11 | Acting-for binding | an agent in the run of a person reads or writes content that the person may not read or write respectively |
| AZ-P12 | SQL predicate | a reading sqlc query on content tables exists without = ANY($visible) and without an entry in the exception list (section 7.4, rule 1) |
| AZ-P13 | Denials in the audit | a denial of a request or op is missing from the audit log or is not aggregated there (audit.deny_aggregate_window), or a filter (section 4.2) writes a denial |
12. Decisions of this chapter¶
| No. | Decision | Reason |
|---|---|---|
| R-RE-01 | Permission cache with a global version counter and valid_until; no materialized view. |
One truth (R-DM-19); expiry by time is correct without a write. |
| R-RE-02 | Every REST request reads the current permission version. | Revocation takes effect across processes without a time window; the cost is one row per request. |
| R-RE-03 | Agents and apps get only upload as a capability. |
No self-approval, no administration, no mass export by bots. |
| R-RE-04 | Operator rule: the effective operator implicitly has write. |
Follows the operator without a second grant; nothing is left over after a change. |
| R-RE-05 | Tool class host only for owners or an own ready dev environment, checked at invocation. |
Study C P6(b); readiness changes without a permission transaction. |
| R-RE-06 | Acting-for binding: an agent in a run has at most the permissions of the triggering person. | Closes the confused deputy through the server API. |
| R-RE-07 | Read horizon as a hint, not as a block. | A block would make channel.create fail unexpectedly; only the instance operator knows the memory of a runtime. |
| R-RE-08 | manage revokes only what it could have created itself; grants to agents and apps only by the owner. |
No permission escalation through delegation; agent grants are data flows to processors. |
| R-RE-09 | The expiry job only reports; the decision never depends on it. | Study C: expiry is checked on every call; clients still clean up. |
| R-RE-10 | Preview as a dry run in the real transaction with rollback. | The same logic as the real write; plain text from templates, not from a model. |
| R-RE-11 | Host as its own target with only terminal; SR10, SR11 and SR12 as guard rules. |
A terminal is remote access to a host; neither inheritance nor delegation may open it, and no account on the host is shared between people (Ruling T1, T2, T12). |
13. Alignment with the core contract¶
The additions requested during drafting are decided (integration of 2026-10-10):
| No. | Addition | Decision, location |
|---|---|---|
| K-RE-1 | Check: agents and apps get only upload as a capability |
adopted, 03 grants, R-DM-20 |
| K-RE-2 | Operator rule in "Effective level" | adopted, 05 section 2.3 item 4, R-AZ-13 |
| K-RE-3 | agents.tool_class, agents.reported_tool_class, agent.tool_class.set, claim tool_class |
adopted, 03, 05 |
| K-RE-4 | dev_environments, dev_environment_id on sessions and runs, claim, actions |
adopted as the milestone "Multi-user dev environment", 03, 05 section 4.13 |
| K-RE-5 | SR8 also for user.disable |
adopted, 03 owners, 05 |
| K-RE-6 | grant.revoke within R-AZ-05; grants to agents and apps only by owners |
adopted, 05 section 4.4, R-AZ-05 |
| K-RE-7 | Action access.preview |
adopted, 05 section 4.2 |
| K-RE-8 | System actions grant.expire, group.member.expire, event access.expiring, expiry_reminded_at |
adopted, 03, 05 |
| K-RE-9 | Reason codes from 4.1 as values of audit_events.deny_reason |
adopted without a database check: list closed by a test (R-DM-28) |
| K-RE-10 | Owner permissions only with a session | adopted, 05 section 2.3, R-AZ-14; current state: the exception is the check without a request (05 section 5, R-AZ-29) |
14. Configuration values of this chapter¶
| Name | Default | Meaning |
|---|---|---|
authz.expiry_sweep_interval |
1m |
interval of the job that reports expiries and writes reminders |
authz.expiry_reminder_lead |
48h |
how long before expiry the reminder comes (Study C: two days) |
authz.expiry_sweep_batch_max |
500 |
rows per transaction of the jobs authz.expiry_sweep and authz.expiry_reminder |
authz.view_as_max_duration |
15m |
maximum duration of "View as person" |
authz.snapshot_cache_max_entries |
10000 |
permission snapshots in memory per process (LRU) |
authz.not_found_timing_tolerance |
20ms |
allowed deviation of the median in AZ-P4 |