Skip to content

Authorization

One decision, authorize(actor, action, resource) → allow | deny(reason), over a policy table of data rows, is the only mechanism that decides what a caller may do. Identity is resolved once per surface into a typed Actor; every gate is a policy row; channel visibility is the relation member-of(actor, channel) and holds on every surface — Slack, HTTP ingress, MCP, the Access-gated /api, the CLI, the schedule shim, and memory reflection. Machines get their own gated identities, and a token must not read another team's runs (a denied read is not_found). The weekly self-improvement cron seeing zero runs, an Access operator reading a private-channel run, and a private-conversation fact becoming org memory are the three leaks the model closes — as consequences of one table, not as special cases.

Behavior

  1. Actors — resolved by the surface, never trusted from the request. An Actor is { kind, id, grants, onBehalfOf?, origin? }. kind ∈ { user, service, schedule, agent }; id is platform-namespaced (invariant 4) and is the ONE identity every later decision keys on: slack:U… (a Slack user), http:<subject> (an ingress token's subject), mcp:<subject> (the same token over MCP), access:<sub> (a Cloudflare Access browser session), access:svc:<common_name> (an Access service token), cli:local, schedule:<name>, agent:<name>. Each adapter returns exactly what it can prove and nothing more: Slack the user id of the event; HTTP/MCP the token entry the bearer matched; the Access gate the verified JWT's sub or common_name; the CLI the fixed local identity; the schedule shim the registry entry that fired. Grants are never attached by the adapter: one resolver, grantsFor(actor, config), reads them from configuration (item 9), so two surfaces resolving the same id get the same grants. origin (a chat actor's { channelId, threadKey }) is context for the channel-scoped commands and the channel memory scope — never authority (command-registry.md item 21). An agent actor carries onBehalfOf, the principal whose run it is; its effective grants are the intersection of its own and the principal's, computed per GrantSet ("all" ∩ S = S, S ∩ T set-wise) — an agent never exceeds the person it acts for. An actor the adapter cannot resolve is NO_GRANTS on every axis. Adapters make no authorization decision: no scope comparison, no channel pin, no gate resolver — building the Actor is the whole of their part.

  2. Actions — today's vocabulary, no new words. An Action is a command's effect class <group>:read | <group>:write | <group>:exec (runs:read, friction:write, repo:exec, …), or one of the three non-command actions: agent:run:<name> (start the named agent — decided in dispatch(), invariant 3; agent:run:* = every agent), repo:use (act inside a repo's resident environment), memory:write (reflection writing a fact to a scope). A command declares its action once in defineCommand; write never implies exec, and no action authorizes a run.

  3. Resources — typed, with the attributes rules read. run { id, channelId, userId, repo?, channelVisibility? }; channel { id, visibility }; memory-scope { key, kind: org | user | repo | channel, originChannelVisibility? }; repo { owner, name }; config-scope { kind: channel | user, id } and config-scope { kind: org } (the three config tiers, where MCP servers live too); agent { name }; and command { id } for list-shaped actions with no single resource (runs.list, friction.report) and for every command whose definition resolves no other resource. ChannelVisibility ∈ { public, private, dm, machine, unknown }; machine is every http:* / mcp:* channel, whose members are the tokens granted it. A handler names the resource it is about to touch; nothing about a resource is inferred from the request.

  4. The policy table — data, closed vocabulary, validated at load. A Rule is { action, resource, actorKinds?, resourceKind, originVisibility?, when: Condition[] }. Within a row when is ANDed; rows for the same (action, resource) are ORed; no matching row is a deny. A row also carries one-sided selectorsactorKinds (the actor's kind), resourceKind (a memory-scope's org | user | repo | channel or a config-scope's channel | user | org — REQUIRED on a row for a kinded resource type and FORBIDDEN on every other, typed per resource as KindOf<T> and enforced by the Rule type: a memory-scope row without a kind or a run row with one does not compile), originVisibility (the resource's origin channel visibility) — that pick WHICH rows apply to a request and read one side only, as distinct from the five conditions below, which relate actor to resource; the org-write rule (item 8) is an originVisibility: [public] selector on the memory:write × memory-scope { kind: org } row — a point-check-only row, never compiled into a list predicate. The condition vocabulary is CLOSED: has-grant(g) (actor.grants.actions contains g or is "all"), member-of — ONE definition, used by both evaluators: it holds when EITHER actor.grants.channels contains the resource's channel id (or is "all") OR the channel's visibility is public (resource.channelVisibility, stamped on the run at dispatch; unknown is never public). Channels are channels: there is no machine-channel vocabulary, no channel-prefix rule — an http:/mcp: channel is a member's channel exactly when the grant names it. A later cut adds directory-confirmed membership for private channels behind the same seam without changing the definition's shape — is-self (resource.userId equals the actor's id, or the onBehalfOf principal's), owner-of (actor.grants.repos contains the resource's owner/name, or is "all"), all-channels (actor.grants.channels === "all"). Every member is both evaluable against one resource and compilable to a store predicate — that is the admission test for a condition; a need for an arbitrary predicate is a plan-level stop, not a local addition. The table is validated when the module loads: a rule naming an unknown condition kind, an unknown resource type, or an action no command declares throws at import, so a malformed table can never serve a request. Every admission is a row, one vocabulary for humans and machines: every command row is <action> command [has-grant(<action>)], and what differed by surface is now what each actor HOLDS (item 9) — open → the actions of those commands are the baseline every Slack user holds (CHAT_OPEN_ACTIONS: help|config|repo|friction|memory|mcp|schedule:read, memory:write, mcp:write), a browser session every <group>:read, a token exactly its scopes, so a dispatch-only token is refused by the same row that admits a Slack user; operatorhas-grant(<group>:write) (or :read for the read commands) — the grant the admins' all translation and an operator's grants carry; repoManagerhas-grant(repo:write) (and friction:write for friction propose); channelConfighas-grant(config:write) on config-scope { channel }, asked by the config.* handlers for the channel scope (the config:write grant: never a baseline — its holders, admins, Access operators, and tokens minted with it) — membership is NOT a condition of that row: a chat user may target another channel with --channel, and no adapter proves channel membership until the directory (item 7's gap), so a member-of there would deny every non-admin config set channel; the command itself has two config:write rows on commandactorKinds: [user] with no condition (a person always has their own scope to write) and has-grant(config:write) (a credential needs the grant); agentRunrepo test|build resolve the resource agent { coding } (CommandDef.resource), decided by repo:exec agent [has-grant(agent:run:{name})] (the right to run the implicit target agent) or repo:exec agent [has-grant(repo:exec)] (the exec grant a token was minted with) — which agents an actor may run is an action, not a grant axis (Grants has exactly three: actions, channels, repos). The MCP tiers are mcp:write rows on config-scope { channel } (has-grant(config:write), or has-grant(mcp:write) for a service) and config-scope { org } (has-grant(repo:write), or has-grant(mcp:write) for a service); forgetting a shared memory record is the repo:write right. A run read is runs:read ∧ (member-ofall-channels); a run resource with is-self is how a DM run belongs to its user.

  5. authorize — the one public decision. authorize(actor, action, resource) is the ONLY export command code, dispatch(), and reflection call; it evaluates the rows for (action, resource.type) against the actor's effective grants and returns { allow: true } or { allow: false, reason }. The command registry asks it FIRST in invoke on every surface — authorize(caller.actor, cmd.action, resource), where a command declares its action once and, when the resource is not the command itself, a resource(rawInput, caller) resolver (repo.test|buildagent { coding }; everything else command { id }) — before parsing, so a refused caller learns nothing about the schema. The reason names the failed condition (no rule for runs:read on run, not a member of slack:C…, missing grant friction:write) and goes to the audit line only (command-registry.md item 7); a reply never carries it. A denied point read of a resource (runs get <id>, runs events, runs friction, runs stop, and the tokenless run page with its events, friction and stop routes) renders as not_found, byte-identical to a run that does not exist — existence is never revealed. A denied write or list is unauthorized with the shared 🚫 wording, whose golden texts are unchanged. Handlers never compare ids or channels by hand: grep -rn "caller.channel\|chatGate" src/ outside src/core/authz/ is empty.

  6. List reads — the same rules, pushed into the store. predicateFor(actor, action, resourceType) compiles the rows for a list-shaped action into a Predicatenone, all, channels-in(ids), repos-in(repos), user-is(id), visibility-in(visibilities), or an and / or of those (a row's ANDed conditions → and, the ORed rows → or) — from the SAME table authorize reads: all-channelsall; member-ofor(channels-in(actor.grants.channels), visibility-in(["public"])) — the same two-sided definition as item 4, so the run store filters on the stamped channelVisibility column and a public run is listed for everyone (the visibility-in leaf is the one addition to the contract's Predicate, landing with the store wiring); owner-ofrepos-in(actor.grants.repos); is-selfuser-is; no matching row → none. It is callable only from the store adapters (RunsService, RunStoreFrictionLedger, the memory stores), which hand it to the store as its own filter: RunListOptions.visibleTo is the predicate's wire form (RunVisibilityFilter in runRecord.ts — the same tree with its sets as arrays), the in-memory and file stores evaluate it with matchesVisibility (the one truth table), the run-history DO compiles it to SQL (channel_id IN (…), channel_visibility IN (…), user_id = ?, repo IN (…), each on an index ordered like the page — never a full scan), none never touches the store, and all sends no filter — so a list returns only what a point read would have allowed and no surface loads records and filters afterwards. A store that receives a malformed filter answers 400, never all. A denied list is an empty page, not an error. The invariant that makes this safe is the predicate ⇔ authorize differential: for any actor and any fixture of records, filtering by the store predicate equals filtering by point authorize on each record — the load-bearing test of the module. runs list without a store predicate (predicateFor returns none for an unknown actor) lists nothing.

  7. Channel visibility — a relation, on every surface. member-of(actor, channel) and the all-channels grant are the two ways a run is readable; the Access API is bound like every other surface (an operator without all-channels gets not_found on a private-channel run they are not a member of), and so are the /runs pages: src/index.ts resolves the verified Access identity with the same accessActor the /api adapter uses and hands it to the live-view handler, whose index lists (the default live rows, ?all=1, the ?stream=1 feed, the Scheduled tab's live links) through predicateFor(actor, "runs:read", "run") and whose tokenless finished-run routes (page, events, friction, and the 409 a tokenless stop gives a finished run) authorize the run's own attributes — each after the runs:read command admission /api/runs.list / runs.get ask first, so the same identity sees on the page exactly what the API answers, and a deny is the same 404 as an unknown id with the reason on the audit line only. The capability-token live page is untouched: the token IS the capability and the actor is not consulted on it (live-view.md). ChannelDirectory { info(channelId) → { visibility }; isMember(actorId, channelId) → boolean | "unknown" } is the adapter seam that supplies channel facts: it is the SOURCE of the channelVisibility stamp (dispatch asks it once per run) and, later, of membership facts — it is not consulted at authorize time, which reads only the stamped resource and the actor's grants; unknown — a channel the directory has never seen, an API failure, a timeout — is never public and never a member. The first cut is visibility-only, a consequence of item 4's member-of: public → every actor of the workspace is a member; private and dm → the run's own userId (is-self) and holders of all-channels; machine → the tokens granted that channel (grants.channels). Real Slack membership (conversations.members, TTL-cached, one page per channel per TTL) lands behind the SAME seam without a handler change ([gap] below); a stale-allow window of minutes after a member leaves is accepted and documented, a stale-deny is impossible because unknown denies. Every run is stamped with channelVisibility at dispatch (dispatch() asks CoreDeps.channelDirectory once per run and writes the answer into the RunMeta and the record), so neither read-side membership nor the memory write gate needs a Slack call at read time; a record without the stamp is unknown → private for writes, membership-gated for reads. Two directories implement the seam. The static one (src/core/authz/channelDirectory.ts, visibilityOf — the dispatcher's default, what the CLI and the tests get) is what the platform-namespaced id alone establishes: http:* / mcp:*machine, slack:D…dm, slack:G…private, a Slack C… channel → unknown, because only conversations.info can tell a public C… from a private one. The Slack one (src/channels/slackChannelDirectory.ts, SlackChannelDirectory, wired by the bot in src/index.ts as soon as the Slack adapter is up) asks conversations.info for a slack:C… or slack:G… id — is_im or is_mpimdm (a group DM is private to its members like a DM, and its facts belong to the person, item 8), is_privateprivate, else public — once per channel per TTL (CHANNEL_INFO_TTL_MS, ten minutes; a bounded FIFO cache of 1000 channels like the adapter's name caches; concurrent first lookups share one call), and answers unknown on any failure — an API error, a missing scope, a reply without a channel — remembering the failure for the same TTL so a failing channel costs one Slack call and one [authz] log line per window, never a call per message (a failure is grants-only visibility, never public). A slack:D… id is dm from the id alone and never reaches the API (no im:read needed); a non-Slack id is the static answer. The lookup needs the bot's channels:read (public C…) and groups:read (private C…/G…) — both already in the required set the startup scope check enforces (slack-channel.md item 6a). The dispatcher bounds the wait: a directory slower than CHANNEL_DIRECTORY_TIMEOUT_MS (1.5 s — a cold conversations.info is one round trip; every later message in the channel is a cache hit) stamps unknown and the run proceeds, so a Slack outage costs a reply at most that long. Consequence: a run in a public Slack channel is stamped public and readable by every actor (member-of's public half) — a plain Slack user's friction report now counts the public channels' runs, their own, and the channels their grants name; a run in a private channel or a DM stays readable through channel grants, all-channels, or is-self only; admins and operators (all) are unaffected. A machine token's pinned channel is the same relation: channels: {"http:ops"} sees http:ops (plus any run stamped public) and nothing else; a token with NO channel grant sees only public runs and its own.

  8. Memory — reflection writes are gated. For every candidate fact (and the summary), reflection resolves the extractor's audience to one of the run's scopes exactly as before (memory.md item 23) and then calls authorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility }), where originChannelVisibility is the run's stamped channelVisibility (item 7) and runActor is the run's principal (the same actor the chat commands resolve for the message's user id) holding the run's own channel and repo as memberships (reflectionActor): both are facts the dispatcher established before the run — the adapter delivered the message from that channel, the repo gate admitted that repo — stated on the membership axis so the member-of / owner-of rows recognize the run's own scopes; no action is added and "all" is left alone, so the one question left to the table is whether the origin may write org. That question: the org row carries the selector originVisibility: [public, machine], so a fact whose run originated in a private or dm channel — or whose run is unstamped / unknown (a directory failure, a timeout, a record from before the stamp) — has no org row and is narrowed, never widened, never silently dropped: a dm origin narrows orguser (a DM is one person's conversation with the bot; its facts are that person's and travel with them), a private or unknown origin narrows orgchannel (the origin's own audience — exactly its participants), and either falls to the other when the run lacks the first; repo is never a narrowing target, because a repo scope is read from every channel the repo is used in — wider than the origin. A narrowed correction is written without its supersedes: the record it would have retired lives in the scope the origin may not write, so that record stands. user, channel, and repo facts stay where the extractor put them (the audience is a hint the policy narrows, never widens); a fact from a public or machine origin keeps today's routing, org included. Any write the table denies for another reason — a candidate whose scope the actor is not a member of — is dropped, never rerouted wider. Every narrowing or drop is one [memory] line per reflection carrying counts and reason tokens only (2× org → user (origin-visibility)), never a fact's text. Reads are unchanged (memory.md item 22): memoryContextBlock derives its scopes from the request and never calls authorize.

  9. Configuration — one grants shape and a restrict block. grants: { <actor id>: { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all" } } keyed by platform-namespaced actor id — three axes, no more (agents are actions, item 2); an absent axis is the empty set, "all" is explicit and never a default. restrict: { agents?: string[], repos?: string[] } names what is closed unless granted: a listed agent runs only for an actor whose grants hold agent:run:<name> (or all), a listed repo is used only by an actor whose repos axis names it (case-insensitively, or all); everything unlisted is open to everyone who can reach the bot, and restricting one thing never takes a grant from anyone. restrict.agents must name registered agents and restrict.repos owner/name slugs, or the load fails naming the offender. grantsFor(id) is a namespace baseline plus the id's entry: a slack: id holds the open chat commands (CHAT_OPEN_ACTIONS) and agent:run:<name> for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); an access:<sub> browser session holds every registered group's read, an entry adds; access:svc:, http:, mcp: credentials hold exactly their entry, unlisted NO_GRANTS; a schedule: id holds what the schedule registry declares for it (actor: { kind: "schedule", id: "schedule:<name>", grants }, no config list hand-names http:cron) unless the block names it, then config wins whole; cli:local everything. A key <ns>:*slack:*, http:*, mcp:*, access:* — is a surface entry: the same three axes, held by every actor that authenticated on that surface (access:* is every browser session, the org Cloudflare Access admits — never an access:svc: token). An actor's grants are the union of its own entry (or its baseline), and its surface entry: a personal entry never narrows the surface entry, so a person listed for extra rights keeps what everyone holds. * is only ever a whole surface: a partial subject (slack:U*) and schedule:*, access:svc:*, agent:*, cli:* fail the load naming the key — schedules and service tokens are individually named identities, an agent derives from its principal, the CLI holds everything. A surface entry is never an actor: adminsHint names people, and an id no surface owns inherits no * entry (fail-closed, item 10). config:write, repo:write, and every runs:* are never a baseline. grants and restrict are the only authorization keys: any other top-level key (permissions among them) is unknown and fails the load by name, and an ingress token entry is exactly { subject, channel? } — any other field (scopes, say) is ignored, so nothing in the token map can widen the grants entry. adminsHint / canManageRepos / canEditChannelConfig / canRunAgent / canUseRepo / restrictedAgentsFor all answer from the grants table. The baselines:

    Actor idBaseline (held listed or not)A grants entry …
    slack:U…{ actions: CHAT_OPEN_ACTIONS + agent:run:<name> for every agent not under restrict.agents }help:read, status:read, config:read, repo:read, friction:read, memory:read, mcp:read, schedule:read, memory:write, mcp:writeadds to it
    access:<sub>{ actions: every <group>:read } (the groups the store is handed at startup)adds to it
    access:svc:<cn>, http:<subject>, mcp:<subject>nothing (NO_GRANTS)is exactly what it holds — dispatch included
    schedule:<name>the registry's declared grants (self-improvement: { actions: {friction:read, friction:write}, channels: all })replaces them whole
    cli:local{ actions: "all", channels: "all", repos: "all" } — the local operator
    slack:*, http:*, mcp:*, access:* (a surface entry, not an actor)is unioned into every actor of that surface, on top of its own entry and baseline; access:* never reaches access:svc:
  10. Fail-closed defaults — all preserved, enumerated. An unresolvable actor has NO_GRANTS. A missing grant denies. No (action, resource) row denies. No admins configured means no operators (the shared 🚫 text names nobody to ask). unknown membership or visibility is not a member. A channelVisibility-less record is unknown. A schedule whose actor has no token sends nothing and records misconfigured. A malformed scopes or grants entry skips the identity rather than widening it. A ChannelDirectory failure yields unknown, never allow. An agent actor without onBehalfOf has its own grants only; with one, never more than the principal.

  11. Resolved questions, stated as behavior. Admins hold all-channels; an operator is membership-bound unless its channels is "all" — the fleet view is one grant away, never implicit. friction report computes over the runs the actor can see: RunStoreFrictionLedger.recent() takes the actor's predicate, so a Slack user's report reflects the public channels plus their own runs and the channels their grants name, a pinned token's its channel, an all-channels holder's the fleet; the aggregate is per-actor by construction and a fleet view is the all-channels grant. Visibility-only membership (item 7): the Slack directory supplies each channel's visibility from conversations.info, so public channels are everyone's and private channels and DMs are their user's and their grantees'; real Slack membership (conversations.members) is the remaining [gap] behind the same isMember seam, which answers unknown until then. Channels are channels: an ingress token's channel is where its dispatches are recorded, never a grant; a token reads no run (public runs and its own aside) until its grants entry names channels or all; the per-request pin ("the channel it speaks in") is gone. This is a deliberate change: it narrows tokens to what config says — a deployment grants its fleet's tokens (the cron token, an ops service token) natively, and every other token must be granted. The self-improvement schedule actor is declared in the registry (scheduleActor("self-improvement", …)) with { actions: {friction:read, friction:write}, channels: "all" } — the floor grantsFor serves for schedule:self-improvement (a grants entry naming it replaces) — so the weekly pass analyzes every channel's runs. Until the shim hands firings to that actor, the dispatcher still sees http:cron, whose grants entry must carry the same reach. Everyone in the org: access:* gives every Access browser session what the entry says — Cloudflare Access already decides who may log in, so the org is granted once, never enumerated; slack:* does the same for every workspace member the bot hears. A restricted agent runs for an actor whose effective (unioned) grants hold agent:run:<name> — a surface entry can open it to everyone on that surface, and a denial still names the actor, never the surface key.

  12. What the operator log lines carry (tracing.md items 21–22). A span log line names run ids and channel ids by design — they are the operator's handles — and never a token, a query string, a body or a free-text remote error. A denied read leaves no span line anywhere: the public shim's root for a request that ended 401/403 is dropped by its sink's filter, and an internal Worker starts its root only after the bearer checked out, so a refusal's audit line (where one exists) stays the only record of it and carries no run id. The bot's own span log (GET /admin/trace/log, tracing.md item 26) is the one way to read those lines from outside the container, and it opens only to an ingress bearer whose actor holds trace:read — a grant nobody holds by baseline — behind the same rule as /admin/crash.

Validation criteria

CriterionEvidence
Text a resident supplies (reason, error, summary) never reaches a card, a reply, a listing or a record carrying a credential, a control sequence or another thread's identifier: sanitized at the resident's write and exit and at the bot's parse (resident-repos item 62)[unit] src/execution/residentText.test.ts::sanitizeResidentBody::*; src/execution/resident.test.ts::resident text is made safe at the parse (item 62)::*; src/execution/residentDiskBudget.test.ts::diskPressureReason — the refusal names free, reserve, projected, what was evicted and what was kept::full shape
Every surface resolves an Actor with the platform-namespaced id and kind — Slack admin and plain user (user, slack:U…), token with and without channel (service, http:/mcp:<subject>), Access browser (access:<sub>) and service token (access:svc:<cn>), cli:local, the self-improvement schedule (schedule:self-improvement); grants come from grantsFor, never the adapter; an unresolvable identity is NO_GRANTS[unit] src/core/authz/actor.test.ts::resolveActor…::*, src/core/commandChat.test.ts::chatCallerFor::*, src/channels/mcp.test.ts::toCaller — the Caller a tool call runs as carries the mcp: Actor…::*, src/channels/commandHttp.test.ts::createCommandHttpHandler — caller resolution …::*, ::callerIdFor…::callerFor carries the identity as the Actor the table decides on…, src/core/schedules.test.ts::schedule registry::every run schedule declares its … actor as schedule:<name> WITH its grants…
agent actors: effective grants are the intersection with onBehalfOf on every axis ("all" ∩ S = S); an agent never exceeds its principal; no principal → own grants only[unit] src/core/authz/authorize.test.ts::effectiveGrants::an agent with \all` on behalf of a member gets exactly the member's grants, ::an agent with narrow grants on behalf of the admin does not widen to the admin's, ::is never a superset of either side…, ::is the actor's own grants when nobody is acted for`
Every row of the table has a positive and a negative test; AND within a row, OR across rows; no row → deny with a reason naming the gap[unit] src/core/authz/policy.test.ts::POLICY coverage::every row has a case and every case names a row (no duplicate rows), ::*::allows its allow cases through this row and through the table, ::*::denies its deny cases in this row and across the table, src/core/authz/authorize.test.ts::authorize: table shape::conditions AND: a held grant without membership denies, ::rows OR: any one satisfied row allows, ::no row for the (action, resource) → deny no-rule
The condition vocabulary is closed: a rule with an unknown condition kind, an unknown resource type, actor kind, or visibility, or a condition on an attribute its resource cannot carry fails at module load (validatePolicy(POLICY) runs on import); the reverse direction — every command's action has a row — is the conformance fence in the chat-gates row below[unit] src/core/authz/policy.test.ts::validatePolicy…::refuses a condition outside the vocabulary, ::refuses an unknown resource type, actor kind, or visibility, ::refuses a condition whose attribute the resource cannot carry, ::POLICY coverage::the table validates at load
An unknown actor kind denies everything; NO_GRANTS denies everything but the open rows[unit] src/core/authz/authorize.test.ts::authorize: table shape::unknown actor kind → deny before anything else, even with every grant, ::authorize: fail-closed …::NO_GRANTS user: denied on every row except the open ones…, ::a NO_GRANTS credential (service) is denied on every command row…
Deny reasons reach the audit line and never the reply; a denied point read is not_found, byte-identical to a missing run; a denied write is unauthorized with the unchanged golden 🚫 text[unit] src/core/commands/runs.test.ts::channel visibility…::an Access operator without all-channels gets not_found outside their channels…, src/channels/commandHttp.test.ts::createCommandHttpHandler — the Access API is bound by channel visibility…::an Access operator without all-channels gets 404 not_found on a private-channel run, byte-identical to a run that does not exist…, src/core/commandRegistry.test.ts::CommandRegistry audit line::the deny reason is the audit line's, never the reply's …, src/core/commandChat.test.ts::handleChatCommand::a non-admin gets the restricted wording other commands use; the handler never runs
predicateForauthorize differential: for every actor fixture (admin, operator, member, non-member, pinned token, schedule, NO_GRANTS) over a fixture of runs across public / private / dm / machine channels, filtering by the predicate equals filtering by point authorize[unit] src/core/authz/predicate.test.ts::predicateFor ⇔ authorize differential over runs::…: predicate filter == point authorize
predicateFor compiles all-channelsall, member-ofor(channels-in, visibility-in(["public"])) (the public half alone for an actor with no channel grants), owner-ofrepos-in, is-selfuser-is, rows OR into one flat disjunction, no row → none; unknown (or a missing stamp) never matches visibility-in(["public"])[unit] src/core/authz/predicate.test.ts::predicateFor: derivation::member-of → or(channels-in over the actor's channel set, visibility-in [public]); is-self → user-is; rows OR into ONE flat disjunction, ::an actor with no channels and no grants keeps the public runs and its own, ::nothing applicable → none; unknown actor kind → none, src/core/authz/predicate.test.ts::matchesPredicate::visibility-in reads the record's stamp; a missing stamp is \unknown` and never public, src/core/authz/predicate.test.ts::predicateFor ⇔ authorize differential over runs::a record stamped `unknown` (or not stamped) never matches the public half — for every actor without the channel`
Stores push the predicate down: RunsService.listRuns and RunStoreFrictionLedger.recent hand store.list the predicate as visibleTo, never filter after loading; none touches neither live rows nor the store; all sends no filter; the in-memory/file stores evaluate matchesVisibility; the run-history DO compiles channels-in / visibility-in / user-is / or / and into the ONE indexed page query (statement spy: no retention scan), answers a malformed filter 400, and bounds its width to the DO's 100 bound parameters[unit] src/core/commands/runs.test.ts::runs.list::passes the actor's predicate to the store and never filters after: the store is asked with \visibleTo`, and `none` never touches it, src/core/runsService.test.ts::RunsService.listRuns — read merge::`visibleTo` is pushed down …, src/core/frictionLedger.test.ts::RunStoreFrictionLedger::recent() takes the actor's predicate (authorization.md item 6)…, src/core/runStore.test.ts:😗 — RunStore contract::list applies `visibleTo` — the actor's predicate — as its own filter…, src/core/runRecord.test.ts::run visibility filter — the wire form of an authz Predicate…:😗, deploy/cloudflare-memory/runs.test.ts::run history routes::list with `visibleTo` (authorization.md item 6)…`
Deliberate change (a): the schedule:self-improvement actor sees runs from every channel — the registry declares its grants (channels: all), a firing from the cron's machine channel analyzes the fleet, not its own firings[unit] src/core/commands/friction.test.ts::friction.report::the self-improvement schedule actor analyzes every channel's runs … (red-first), src/core/schedules.test.ts::schedule registry::every run schedule declares its … actor as schedule:<name> WITH its grants…, src/core/authz/grants.test.ts::grantsTable / grantsIn / grantsFor…::a schedule actor's grants are the registry's declared ones …, src/core/authz/actor.test.ts::resolveChatActor…::slack:/http:/mcp:/cli: prefixes resolve like their adapters would
Deliberate change (b): an Access operator without all-channels gets not_found on a run from a private channel — byte-identical to a missing run, on runs.get/events/friction and over the Access API — and lists only public and granted runs; an operator granted all reads the fleet; the run's own user reads it (is-self)[unit] src/core/commands/runs.test.ts::channel visibility (authorization.md items 5–7)::an Access operator without all-channels gets not_found outside their channels… (red-first), ::an Access operator without all-channels lists only public and granted runs; an admin lists the fleet, ::a run is its user's own…, src/channels/commandHttp.test.ts::createCommandHttpHandler — the Access API is bound by channel visibility…::an Access operator without all-channels gets 404 not_found on a private-channel run… (red-first), ::runs.list from the Access API shows an operator without all-channels only the public runs…
The /runs pages are bound to the Access identity's actor: /runs?all=1 lists an unlisted browser session only the public runs, a native channel grant adds that channel, an admin the fleet, with the actor's predicate handed to listRuns (never a filter after loading); the default /runs and its ?stream=1 feed carry only the live runs the viewer may read (a hidden run's row, token, upserts and removed never reach the page); a tokenless finished run the viewer may not read is the same 404 as an unknown id — the page byte-identical, events and friction the text body, the stop's 409 a 404 — with the reason (not-member) on the audit line and never in the reply; the tokenless stop is a write — it asks runs:write on runs.stop and on the run, as the command surface does — so a viewer who may read a run but not stop it gets that same 404 (missing-grant on the audit line); a viewer holding no runs:read at all sees an empty index and 404s even on a public run (what /api/runs.* refuses outright); a capability token still opens the live page, stream and stop for that viewer; the Scheduled tab links a live firing with its token only for a viewer who may read it[unit] src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle …::the viewer's actor binds the index and the tokenless history routes …::* (red-first: the ?all=1, default-index/feed, denied-run and no-grant tests failed on visibleTo: all), ::scheduled tab — GET /runs/scheduled …::links a live firing with its token only for a viewer who may read that run…
A non-admin Access identity opens /runs and sees only public and granted runs[agent] (human-gated: needs a second Access identity.) As an Access identity configured natively without channels: "all" (or unlisted — the browser read baseline), open /runs?all=1 behind Access: only runs stamped public and runs from channels the identity's grants name are listed, and /runs/<id> for a finished slack:G… / DM run it did not start renders the Run not found page, byte-identical to /runs/nonexistent; wrangler tail switchboard shows [runs] history read {"route":"page","identity":"access:<sub>","denied":"not-member"} and no run id. As an admin the same URLs list and render the run.
Deliberate change (d): an ingress token WITHOUT a channel key holds no channel grant — it lists nothing but public runs and gets not_found on every other run, on the MCP tools and as text in the channel it speaks in; a pinned token sees its channel (plus public runs), never another machine channel or a private run[unit] src/core/commands/runs.test.ts::channel visibility (authorization.md items 5–7)::an unpinned token (no \channel` key) holds no channel…(red-first),::an unpinned token speaking as TEXT is no longer pinned to the channel it speaks in…(red-first),::a pinned token sees its channel and the public runs — never another machine channel or a private run…, src/core/authz/actor.test.ts::resolveActor…::ingress token whose entry names no channel…, src/channels/mcp.test.ts::handleMcpRequest — registry commands as tools::a token's `channel` is its one channel grant…`
Visibility-only membership (member-of's public half): a run stamped public is readable by every actor without a channel grant; private / dm / machine / unknown (or no stamp) are not; a public origin never makes a memory scope public; public makes the member, not the writer (runs:write still needs its grant)[unit] src/core/authz/authorize.test.ts::authorize: fail-closed …::member-of's public half …, src/core/authz/policy.test.ts::POLICY coverage::*
The static ChannelDirectory: http:*/mcp:*machine, slack:D…dm, slack:G…private, a Slack C… channel and everything else → unknown; it knows no members (isMember is unknown); a directory that throws stamps unknown, never a guess[unit] src/core/authz/channelDirectory.test.ts::visibilityOf — the static id mapping::*, ::StaticChannelDirectory::*, src/core/dispatcher.test.ts::run history write path …::channel visibility stamp …
Runs are stamped with channelVisibility at dispatch — the RunMeta, the record, and the interrupted tombstone carry what the directory said; an injected directory is asked once per run; a record or row written before the stamp reads as unknown, in every store and on the DO (one column migration)[unit] src/core/dispatcher.test.ts::run history write path …::channel visibility stamp …, src/core/runRecord.test.ts::isRunRecord::channelVisibility defaults to unknown…, src/core/runStore.test.ts::* — RunStore contract::list applies \visibleTo`…(theold-stylerow),deploy/cloudflare-memory/runs.test.ts::run history routes::a table created before the visibility stamp gains the column with `unknown`…`
Deliberate change (c): a reflection from a dm channel with an org-audience fact (and its summary) writes to user, never org, with one [memory] line carrying the reason token and never the fact text; a private origin narrows orgchannel (the origin's audience), falling to user when the run has no channel scope, and never to repo; an unstamped / unknown run never writes org either (fail-closed); a public or machine origin keeps today's routing with nothing logged; user / channel / repo facts are never widened; a narrowed correction drops its supersedes and the org record stands; a write denied for any other reason is dropped with the reason, never rerouted wider; with no narrower scope allowed the candidates are dropped and said so[unit] src/core/memory/reflection.test.ts::reflect — write gate …::a dm-origin org fact is written to the user scope, never org — and its summary follows it (red-first), ::a private-channel org fact narrows to the channel scope — the origin's own audience; without a channel scope, to the user (red-first), ::an unstamped run (no origin visibility → \unknown`) never writes org: the fact narrows like a private origin …(red-first),::a public-channel or machine-channel org fact keeps today's routing — written to org, nothing logged, ::the audience is a hint the policy narrows and never widens…, ::a correction of an org record from a DM cannot follow it into org…(red-first),::a write the table denies for any other reason is dropped with the reason…(red-first),::with no narrower scope allowed, the fact is dropped and said so — never written to org(red-first); end to endsrc/core/dispatcher.test.ts::cross-session memory WRITE path …::dm-origin memory: an `org` fact from a DM is narrowed to the user's scope, never org — and the same fact from a public channel reaches org …`
The reflection actor is the run's principal holding the run's own channel and repo as memberships — no action added, "all" left alone, the principal not mutated; the dispatcher hands scheduleReflection the chat actor resolved for the message's user id and the run's stamp[unit] src/core/memory/reflection.test.ts::reflect — write gate …::reflectionActor: the run's principal holding the run's own channel and repo as memberships…, src/core/memory/memory.test.ts::memoryContextBlock — reads are not policy-gated …::retrieving every scope of a request never calls \authorize`; the write path (reflect) does — the same spy sees both(theauthorize` call's actor and resource)
Reads are unchanged: memoryContextBlock retrieves every scope of a request without consulting authorize[unit] src/core/memory/memory.test.ts::memoryContextBlock — reads are not policy-gated …::retrieving every scope of a request never calls \authorize`; the write path (reflect) does — the same spy sees both`
The Slack ChannelDirectory: conversations.info maps is_private: falsepublic, is_private: trueprivate, is_im / is_mpimdm; slack:D… is dm with no API call; non-Slack ids are the static answer with no API call; one lookup per channel per TTL (default ten minutes, injectable clock), refetched after; a bounded FIFO cache (1000) that re-fetches an evicted channel; any API error, missing scope, or reply without a channel is unknown — never public — remembered for the TTL and logged once per channel per window; concurrent first lookups share one call; isMember is unknown for everyone (membership not enumerated)[unit] src/channels/slackChannelDirectory.test.ts::SlackChannelDirectory.info — visibility from conversations.info::*, ::SlackChannelDirectory.isMember — the membership seam (not enumerated yet)::*
With the Slack directory behind the stamp, a run in a public channel is stamped public and matches a plain Slack user's runs:read predicate; a private-channel run and a DM run do not; conversations.info is asked once per channel across runs and never for a DM[unit] src/core/dispatcher.test.ts::run history write path …::Slack directory stamp: a public-channel run is visible to a plain Slack user's run reads, a private-channel or DM run is not; conversations.info is asked once per channel across runs
The dispatcher bounds the directory wait: a directory that never answers stamps unknown after channelDirectoryTimeoutMs (default CHANNEL_DIRECTORY_TIMEOUT_MS = 1.5 s), logs [authz] channel directory timed out, and the reply still lands[unit] src/core/dispatcher.test.ts::run history write path …::a slow channel directory cannot hold a reply: past \channelDirectoryTimeoutMs` the run is stamped `unknown` and proceeds`
grants parses fail-closed: an absent axis is the empty set, all explicit; an unknown id prefix, a misspelled all, an unknown field (the removed agents axis included), a non-mapping block, or an empty name fails the load naming the id and field — never widens; a whole-surface key (slack:*, http:*, mcp:*, access:*) parses like any entry, and * anywhere else — a partial subject, schedule:*, access:svc:*, agent:*, cli:* — fails the load naming the key[unit] src/core/authz/grants.test.ts::parseGrantsConfig — the native \grants` block::, src/config.test.ts::grants config — the one shape …::`
restrict parses fail-closed: absent → nothing restricted; an unregistered agent, a non-slug repo, an unknown field, or a non-list fails the load naming the field; repos compare case-insensitively; an unrestricted agent or repo is open to anyone, a restricted one only to a holder of agent:run:<name> / a repos grant (or all); restrictedAgentsFor names what an actor may not run; an unknown top-level key (permissions is one) fails the load naming it, nothing mapped or ignored[unit] src/core/authz/grants.test.ts::parseRestrictConfig — what is closed unless granted::*, ::mayRunAgent / mayUseRepo — open unless restricted, then only for a holder::*, src/config.test.ts::restrict — closed unless granted (authorization.md item 11)::*, ::permission gates::*, ::per-repo access (canUseRepo)::*
The baselines and the lookup: slack: → the open commands + every unrestricted agent (never config:write, never runs:read), access:<sub> → every group's read, credentials and unlisted schedules nothing; a slack: or browser entry ADDS to its baseline, a credential holds exactly its entry, all absorbs the baseline, a schedule's declared grants are replaced by a native entry; adminsHint / canManageRepos / canEditChannelConfig answer from the table; an ingress token entry is exactly { subject, channel? }, any other field ignored; a surface entry (<ns>:*) is unioned into every actor of its surface on top of its own entry and baseline — alone, beside a wider personal entry, beside a narrower one (the union wins) — access:* never reaches an access:svc: token, an id no surface owns stays NO_GRANTS, a restricted agent opens through a surface entry's agent:run:<name>, and adminsHint never names a surface[unit] src/core/authz/grants.test.ts::the baselines — what an id holds by its namespace, listed or not …::*, ::grantsTable / grantsIn / grantsFor — the lookup::*, ::surface entries — what every actor authenticated on a surface holds::*, src/core/authz/actor.test.ts::resolveActor…::*, src/config.test.ts::grantsFor — the grants the policy table decides on::*, src/core/ingressTokens.test.ts::parseIngressTokenMap::an identity is exactly { subject, channel? }…, src/channels/http.test.ts::parseIngressTokens — a token is a credential, its rights are config's::*
Schedule actors are declared in the registry with their grants; a firing is authorized by the table (no http:cron in any permissions list); a schedule whose actor has no token sends nothing and records misconfigured[unit] src/core/schedules.test.ts::schedule registry::every run schedule declares its … actor as schedule:<name> WITH its grants…, ::planScheduledFiring…::fail-closed: no token map, an unparseable map, or no … identity → nothing to run, with the reason
Every admission is a policy row, decided the same on every surface: the Slack baseline (the open commands), repo:write, config:write for the channel scope (never a baseline, asked by the handler on config-scope { channel } with its own reply text), agent:run (`repo testbuildresolveagent { coding }); defineCommanddeclaresactionand nothing else about admission; every command incommands/all.ts` has a row on the resource it authorizes and a command without one fails the suite by name and is refused for everyone
The fixed actor set × every command × the surface that carries each actor: admission equals authorize (Slack admin / user / repo:write / config:write holder; dispatch-only, every-read, every-write tokens; an every-read Access service token; an unlisted and an operator browser session; cli:local), resolved through the real adapters; the printed matrix's Authorization table is those decisions[unit] src/core/commandConformance.test.ts::command conformance — $id::authorization …: for every actor of the fixed set…, ::command conformance — catalogue fences::authorization: the fixed actor set resolves through the real adapters…, ::scripts/command-conformance-matrix.ts prints this suite's matrix…
Fail-closed defaults hold: no admins → nobody holds everything; dispatch-only token refused on every registry command; runs:write refused on friction:write; write never implies exec; a browser session never holds exec; a grant-less credential is refused on every command row[unit] src/core/authz/authorize.test.ts::authorize: fail-closed …::*, src/core/commandConformance.test.ts::command conformance — $id::auth: a credential without the grant is refused BEFORE parse…, src/core/commandRegistry.test.ts::CommandRegistry.invoke — auth before parse::dispatch-only MCP caller is refused…, ::a runs:write caller is refused on a friction:write command, src/config.test.ts::grantsFor — the grants the policy table decides on::no \all` entry → nobody holds everything (fail-closed)`
The deny reason reaches the audit line and never the reply[unit] src/core/commandRegistry.test.ts::CommandRegistry audit line::the deny reason is the audit line's, never the reply's …
friction report computes over the actor's visible runs: a token granted one channel analyzes that channel, all-channels yields the fleet, no channel grants analyzes nothing; a ledger of bare records (no run store) answers only an all-channels actor[unit] src/core/commands/friction.test.ts::friction.report::analyzes only the runs the actor can see …, ::is open to any chat caller and renders the exact pre-migration reply for an actor that sees every channel; a caller with no channel grants is admitted too and analyzes 0 runs…
The weekly cron analyzes the fleet[agent] With the deployed config granting http:cron and schedule:self-improvement channels: all and the cron token present: temporarily reschedule the self-improvement registry entry + wrangler.jsonc to fire within the hour, deploy, then wrangler tail switchboard shows [schedule] self-improvement → completed run <id> — 🔍 N runs analyzed with N > 0 while other channels have finished runs in the retention window; the run page's Answer lists patterns across channels (Slack and machine). Revert the schedule after.
An Access operator is bound by membership on the Access API[agent] As an Access identity configured natively (grants: { access:<sub>: { actions: [runs:read, …] } }, no channels), GET /api/runs.get?id=<id of a finished run from a private Slack group (slack:G…) or DM the identity did not start>404 {"error":"run not found","code":"not_found"}, byte-identical to ?id=<nonexistent>; GET /api/runs.list?status=all omits the run; the same request as an admin (or an operator granted all) returns it.
An ops token granted channels: all still lists the fleet; a token with no channel grant gets nothing[agent] After the deploy: GET /api/runs.list?status=all with an ops Access service token whose grants entry has channels: all lists runs from Slack and machine channels; POST /ingress runs list --status all as an ingress token whose SWITCHBOARD_INGRESS_TOKENS entry has no channel and no grants entry returns an empty list, and runs get <id> for any known run is not_found.
A DM run is visible only to its user[agent] DM the bot from account A and let the run finish (a slack:D… id is dm from the id alone, in both directories); in Slack as A, runs list --status all (as an operator) lists it; as account B (an operator configured natively without channels: all), runs list --status all omits it and GET /api/runs.get?id=<id> as B is not_found; as an admin it is present.
friction report reflects the caller's grants and the channels' visibility[agent] @switchboard friction report as an admin reports the fleet's count; the same command as a Slack user with no grants entry reports the runs of every PUBLIC channel plus that user's own; a user granted channels: [slack:G…] (a private channel) natively reports that channel's runs too.
A public-channel run is visible to a non-admin Slack user; a DM run is not[agent] (human-gated: needs a second Slack account.) In a public channel, let a run from account A finish; as account B — a Slack user with no grants entry and no admin/operator role — @switchboard friction report in any channel counts that run (compare runs list --status all as an admin for the id), and wrangler tail switchboard showed no [authz] conversations.info failure for that channel. Then DM the bot from A and let the run finish; B's friction report count does not move and GET /api/runs.get?id=<the DM run> as B's Access identity is 404 not_found; as an admin both runs are listed.
A private-channel org fact is narrowed, never written to org[agent] (human-gated: needs a private channel and memory.enabled.) In a PRIVATE channel (slack:G…, or a C… channel conversations.info reports is_private: true), run a tool-using request whose thread states a durable org-wide fact (e.g. "remember: the org standup is at 10am"); after the reply, wrangler tail switchboard shows [memory] slack:<channel>:<ts> write narrowed by policy: N× org → channel (origin-visibility) and no fact text; memory list --scope org as an admin does not show the fact, memory list --scope channel in that channel does; the same request in a public channel lands it in memory list --scope org.

Roadmap (gaps)

CriterionEvidence
Real Slack membership behind the seam: SlackChannelDirectory.isMember from conversations.members (TTL-cached, one page per channel per TTL) instead of today's unknown; a member of a private channel who is not the run's user sees its runs; leaving the channel denies within the TTL; member-of consults it for private channels without changing the definition's shape (item 4)[gap] visibility-only is the shipped cut (item 7): the Slack directory supplies visibility, the isMember seam answers unknown
Agent actors gate tool-level actions: a coding agent's repo:use / push target decided by authorize(agent actor, "repo:use", repo) instead of the resident's compound gate[gap] the agent kind and onBehalfOf exist (item 1); the resident gating has not moved
12: a root that ended 401/403 is never written; an internal Worker's root exists only after auth[unit] src/core/trace/workerTrace.test.ts::refusalFilter / workerLogSink::*; the Workers' wiring is receipted live (tracing.md item 22)
12: the span log route opens only to an ingress bearer holding trace:read: 401 without a bearer, 403 with another grant, 503 without the token map (tracing.md item 26)[unit] src/channels/adminTraceLog.test.ts::GET /admin/trace/log::no bearer → 401, a bearer without trace:read → 403…