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.
- Code:
src/core/authz/viewAs.ts(holdsAll,isViewablePerson,viewingRefusal— the words every surface of view-as agrees on, item 17),src/channels/viewAs.ts(thesb-view-ascookie, its two routes,requestersOf,refuseWhileViewing),src/core/authz/types.ts(the shared contract:Actor,ActorKind,Grants/GrantSet/NO_GRANTS,Action,Resource/ResourceType,ChannelVisibility,Condition,Rule,Decision,Predicate,ChannelDirectory);src/core/authz/policy.ts(the table + module-load validation);src/core/authz/authorize.ts(the one public decision);src/core/authz/predicate.ts(predicateFor, store-side only);src/core/authz/grants.ts(grantsFor, thegrants+restrictblocks, the compiled namespace baselines);src/core/authz/actor.ts(per-surface actor resolution,chatActorOf— the actor every dispatch gate decides on);src/core/ingressTokens.ts(the one token-map parser:{ subject, channel?, email? });src/channels/requester.ts(boundRequester: a credential bound to a person, item 15);src/core/authz/channelDirectory.ts(the seam and its static id mapping,StaticChannelDirectory);src/channels/slackChannelDirectory.ts(SlackChannelDirectory:conversations.infobehind the seam, TTL cache);src/core/memory/reflection.ts(the memory write gate,reflectionActor). Adapter touch points:src/core/commandChat.ts(chat actor),src/channels/mcp.ts(token actor),src/channels/commandHttp.ts(accessActor: the Access browser / service-token actor — the one resolver for every surface the Access gate fronts),src/channels/liveView.ts(the/runspages decide with that actor: the index throughpredicateFor, a tokenless run read throughauthorize),src/index.ts(the Slack directory wired as the dispatcher'schannelDirectoryonce the Slack adapter is up; the Access actor handed to the live-view handler),src/core/dispatch/record.ts(the visibility stampchannelVisibilityOf, bounded byCHANNEL_DIRECTORY_TIMEOUT_MS;RecordDepsis the slice ofCoreDepsit reads),src/core/schedules.ts(schedule actors declared in the registry),src/config.ts(grantsschema; item 18: thegithubkey,clearUserOverridepreserving it,userGithubBinding),src/config/validate.ts(validateConfigrules; item 18: the binding's shape and duplicate rules),src/execution/authorBinding.ts(item 18:resolveLogin,resolveById,bindingOf— the binding's reads),src/core/dispatch/references.ts(referenceAccess: source and destination facts checked beforeconversation:read). - Tests:
src/core/authz/viewAs.test.ts;src/core/authz/personalDelegation.test.ts,src/channels/viewAs.test.ts(item 17: the cookie, the routes, the refusal),src/core/authz/policy.test.ts,src/core/authz/authorize.test.ts,src/core/authz/predicate.test.ts,src/core/authz/grants.test.ts,src/core/authz/actor.test.ts,src/core/authz/channelDirectory.test.ts,src/channels/slackChannelDirectory.test.ts; the adapter testssrc/core/commandChat.test.ts,src/channels/mcp.test.ts,src/channels/commandHttp.test.ts,src/core/ingressTokens.test.ts,src/channels/requester.test.ts,src/execution/authorBinding.test.ts(item 18),src/channels/liveView.test.ts,src/channels/slack.test.ts,src/core/schedules.test.ts,src/config.test.ts; the store sidesrc/core/commands/runs.test.ts,src/core/commands/friction.test.ts,src/core/frictionLedger.test.ts,src/core/memory/reflection.test.ts; the regression netsrc/core/commandConformance.test.ts(actor kind × action × resource enumerated from the table),src/core/dispatch/references.test.ts,src/channels/slack/context.test.ts,src/channels/slack/readAccess.test.ts,src/core/dispatch/contextAccess.test.ts; the click (item 16)src/core/dispatch/confirm.test.tsand the confirmation describe ofsrc/core/dispatcher.test.ts.
Behavior
Actors — resolved by the surface, never trusted from the request. An
Actoris{ kind, id, grants, onBehalfOf?, origin?, self?, asUser? }—selfthe ids that mean "me" (the actor's own, and the person a dashboard session is linked to, record 0042), read byis-self,acts-as-person, themetier and the "mine" filters and never by a grant check;asUserthat person, for display and the audit line.kind ∈ { user, service, schedule, agent };idis 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'ssuborcommon_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). Anagentactor carriesonBehalfOf, the principal whose run it is; its effective grants are the intersection of its own and the principal's, computed perGrantSet("all" ∩ S = S,S ∩ Tset-wise) — an agent never exceeds the person it acts for. An actor the adapter cannot resolve isNO_GRANTSon every axis. Adapters make no authorization decision: no scope comparison, no channel pin, no gate resolver — building theActoris the whole of their part.Actions — one closed vocabulary. An
Actionis a command's effect class<group>:read | <group>:write | <group>:exec(runs:read,friction:write,repo:exec, …), or one of the non-command actions:agent:run:<name>(start the named agent — decided indispatch(), invariant 3;agent:run:*= every agent),repo:use(act inside a repo's resident environment),main-task:stop(the requester stops only work bound to their verified main-thread act),memory:write(reflection writing a fact to a scope),schedule:fire(the schedule shim's actor firing a schedule),coordinator:step(a ship coordinator's step —spawn,read-record,pr-check, the instance creation — held by thecoordinatoringress bearer'sserviceactor and admitted to no other kind, an admin'sallincluded: the steps are a program's, and the child a spawn starts is authorized as the requesting user the parent record names, never as the holder of this grant; http-ingress.md item 9), andplan:merge(the plan runner's merge of a unit's pull request — record 0031's merge grant, held by the same bearer by name, aserviceactor and no person, an admin'sallincluded: what decides a merge is the branch and the guards, never the requester, and withdrawing the grant returns every merge to a person; themergestep of http-ingress.md item 9 decides it besidecoordinator:step). A command declares itsactiononce indefineCommand;writenever impliesexec, and no action authorizes a run.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, id, visibility? }(the channel's own visibility, read bymember-of's public half when the scope is read from another channel; absent → never public),config-scope { kind: user, id },config-scope { kind: thread, id }(idthe thread key — the thread scopeconfig set threadwrites, routing-and-config.md item 27) andconfig-scope { kind: org }(channel, user and org are the three config tiers, where MCP servers live too);agent { name }; andcommand { 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 };machineis everyhttp:*/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.The policy table — data, closed vocabulary, validated at load. A
Ruleis{ action, resource, actorKinds?, resourceKind, originVisibility?, when: Condition[] }. Within a rowwhenis ANDed; rows for the same(action, resource)are ORed; no matching row is a deny. A row also carries one-sided selectors —actorKinds(the actor's kind),resourceKind(amemory-scope'sorg | user | repo | channelor aconfig-scope'schannel | user | org— REQUIRED on a row for a kinded resource type and FORBIDDEN on every other, typed per resource asKindOf<T>and enforced by theRuletype: amemory-scoperow without a kind or arunrow 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 conditions below, which relate actor to resource; the org-write rule (item 8) is anoriginVisibility: [public]selector on thememory: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.actionscontainsgor is"all"),member-of— ONE definition, used by both evaluators: it holds whenactor.grants.channelscontains the resource's channel id (or is"all"), OR the actor'smemberOfcontains it (the channels the channel directory says the actor's PERSON is in, resolved once with the actor — a fact about the person, never a grant: item 7), OR the channel's visibility ispublic(resource.channelVisibility, stamped on the run at dispatch;unknownis never public). Channels are channels: there is no machine-channel vocabulary, no channel-prefix rule — anhttp:/mcp:channel is a member's channel exactly when the grant names it.memberOfis read from the root principal likeself(an agent on behalf of a person reads through the person's membership and still needs its ownhas-grant), and no grant check reads it: an actor with and withoutmemberOfholds the same effective grants and passes the same command rows —is-self(resource.userIdequals the actor's id, or theonBehalfOfprincipal's),repo-access(compiled code access covers the resource repo, independently of memory ownership),owner-of(actor.grants.reposcontains the resource'sowner/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 an unlinkeddispatch-only token is refused by the same row that admits a Slack user; requester cancellation is the verified-account exception in item 5a;operator→has-grant(<group>:write)(or:readfor the read commands) — the grant the admins'alltranslation and an operator's grants carry;repoManager→has-grant(repo:write)(andfriction:writeforfriction propose);channelConfig→has-grant(config:write)onconfig-scope { channel }, asked by theconfig.*handlers for thechannelscope (theconfig:writegrant: 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 amember-ofthere would deny every non-adminconfig set channel; thethreadscope (config set thread, routing-and-config.md item 27) is the same right onconfig-scope { thread }— onehas-grant(config:write)row, so whoever may set the channel may set a thread in it and membership alone admits nobody; the command itself has twoconfig:writerows oncommand—actorKinds: [user]with no condition (a person always has their own scope to write) andhas-grant(config:write)(a credential needs the grant); READING another channel's scope — its instructions text included — is the table's decision too: twoconfig:readrows onconfig-scope { channel }, asked byconfig show --channeland by the instructions peek (config instructions channelwith no text) for a named target —has-grant(config:write)for the caller's own actor (whoever may set a channel's scope may read it; the actor's channel grants and a public channel admit throughmember-ofas well) andmember-offor the pointing actor of item 13 (one membership, the origin, no grants), so a public channel's scope is readable from anywhere, a private one only from inside it or by grant, andunknown(no directory, a failed or slow lookup) like a private one; the visibility is the channel directory's, bounded like the run stamp (item 7), not looked up for the caller's own channel; one refusal text for private, DM, unknown and nonexistent alike;agentRun→repo test|buildresolve the resourceagent { coding }(CommandDef.resource), decided byrepo:exec agent [has-grant(agent:run:{name})](the right to run the implicit target agent) orrepo: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 (Grantshas exactly three:actions,channels,repos). The MCP tiers aremcp:writerows onconfig-scope { channel }(has-grant(config:write), orhas-grant(mcp:write)for aservice) andconfig-scope { org }(has-grant(repo:write), orhas-grant(mcp:write)for aservice); forgetting a shared memory record is therepo:writeright. A run read isruns:read∧ (member-of∨all-channels); arunresource withis-selfis how a DM run belongs to its user.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 ininvokeon every surface —authorize(caller.actor, cmd.action, resource), where a command declares itsactiononce and, when the resource is not the command itself, aresource(rawInput, caller)resolver (repo.test|build→agent { coding }; everything elsecommand { id }) — before parsing, so a refused caller learns nothing about the schema. Thereasonnames 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 asnot_found, byte-identical to a run that does not exist — existence is never revealed. A denied write or list isunauthorizedwith the shared 🚫 wording, whose golden texts are unchanged. Handlers never compare ids or channels by hand:grep -rn "caller.channel\|chatGate" src/outsidesrc/core/authz/is empty. A child run is authorized as the requesting user (agent-conductor.md; routing-and-config.md item 20): a run a conductor spawns is adispatch()run whose message carries the requester's ownuserId, so the agent gate, the profile gate and the repository gates are asked of the child with that person's grants — never the parent run's, which holds none of its own — and a child gives a requester nothing they could not start by hand; the run tools' reads (list_runs,get_run_status) arepredicateForandauthorizeunder the same actor, exactly asruns listandruns get, a denynot_foundlike any point read; the session tools (session-log.md item 10) make the same point read on the run itself before touching its log, and a deny reads as an empty result — no hits, no turn — which is what a log with nothing in it answers too. 5a. Requester cancellation.runs.stopuses the separatestop:writeaction. A resolved Slack or Access account may stop its own run; a credential also needsdispatchand a server-verified account link. Only these cancellation rows opt intoacts-as-personwithincludeAccess; other uses remain Slack-only, and Access service-token identities are excluded. The credential and person policies both apply. Existing operators retainruns:writeplus channel visibility. Other run writes retain their existing grants. Stop ownership is checked on the stored run before any stop mutation; unknown and foreign runs sharenot_found. The destructive confirmation remains required, bound to the caller and exact run/mode, and authorization is rechecked on approval. A stop request receipt does not prove execution ended.List reads — the same rules, pushed into the store.
predicateFor(actor, action, resourceType)compiles the rows for a list-shaped action into aPredicate—none,all,channels-in(ids),repos-in(repos),user-is(id),visibility-in(visibilities), or anand/orof those (a row's ANDed conditions →and, the ORed rows →or) — from the SAME tableauthorizereads:all-channels→all;member-of→or(channels-in(actor.grants.channels ∪ actor.memberOf), visibility-in(["public"]))— the same definition as item 4 in the same vocabulary (onechannels-inleaf, wider by the person's channels), so the run store filters on the stampedchannelVisibilitycolumn and apublicrun is listed for everyone (thevisibility-inleaf is the one addition to the contract'sPredicate, landing with the store wiring);owner-of→repos-in(actor.grants.repos);is-self→user-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.visibleTois the predicate's wire form (RunVisibilityFilterinrunRecord.ts— the same tree with its sets as arrays), the in-memory and file stores evaluate it withmatchesVisibility(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),nonenever touches the store, andallsends 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, neverall. A denied list is an empty page, not an error. The invariant that makes this safe is thepredicate ⇔ authorizedifferential: for any actor and any fixture of records, filtering by the store predicate equals filtering by pointauthorizeon each record — the load-bearing test of the module.runs listwithout a store predicate (predicateForreturnsnonefor an unknown actor) lists nothing. The "mine" filter every list surface offers (runs list --mine,/runs?mine=1) isallOf([predicateFor(actor, …), ownedBy(actor)])—ownedByoneuser-isper id in the actor'sselfset, ORed — a narrowing of the readable predicate, never a second decision: a run the policy hides stays hidden, and an actor no run is requested as (an unlinked dashboard session, a service token) owns nothing.Channel visibility — a relation, on every surface.
member-of(actor, channel)and theall-channelsgrant are the two ways a run is readable; the Access API is bound like every other surface (an operator withoutall-channelsgetsnot_foundon a private-channel run they are not a member of), and so are the/runspages:src/index.tsresolves the verified Access identity with the sameaccessActorthe/apiadapter uses and hands it to the live-view handler, whose index lists (the default live rows,?all=1, the?stream=1feed, the Scheduled tab's live links) throughpredicateFor(actor, "runs:read", "run")and whose tokenless finished-run routes (page, events, friction, and the 409 a tokenless stop gives a finished run)authorizethe run's own attributes — each after theruns:readcommand admission/api/runs.list/runs.getask 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"; channelsOf(actorId) → Set<channelId> | "unknown" }is the adapter seam that supplies channel facts: it is the SOURCE of thechannelVisibilitystamp (dispatch asks it once per run) and of the actor'smemberOf(the resolver askschannelsOfonce per actor, for the linked person, waiting at mostCHANNEL_DIRECTORY_TIMEOUT_MSlike the stamp — past it the actor resolves withoutmemberOfand the late answer fills the cache for the next request) — it is not consulted atauthorizetime, which reads only the stamped resource and the resolved actor;unknown— a channel the directory has never seen, a person it cannot list, an API failure, a timeout — is never public and never a member. Visibility alone gives, through item 4'smember-of:public→ every actor of the workspace is a member;privateanddm→ the run's ownuserId(is-self) and holders ofall-channels;machine→ the tokens granted that channel (grants.channels). Membership adds the third way: a dashboard session linked to its Slack person (record 0042) carriesmemberOf= the Slack directory'schannelsOf(person)— every public and private channel the person is in that the bot is also in (users.conversationsfor the person answers within the bot token's own reach; a private channel the bot was never invited to is invisible to it and never had a run) — so a private channel's runs, its config scope and its MCP tier open to the people in it, on the runs index, the run page, the settings tabs and/apialike, with no handler change. Freshness is event-driven first, TTL as the degraded path: the bot subscribes tomember_joined_channel/member_left_channel(forget that person's set — or everyone's when the member is the bot itself, since a channel the bot just joined is now visible on every member's set) and tochannel_left,group_left,channel_archive,group_archive,channel_deleted,group_deleted(the bot's own reach moved: forget everyone's), and forgets everyone on every socketconnected(Socket Mode replays nothing missed while down) — all over the existing Socket Mode connection, no inbound webhook, no new scope (channels:read/groups:readcover the events; the manifest lists them); the per-person cache (MEMBERSHIP_TTL_MS, ten minutes, bounded like the visibility cache, concurrent first asks sharing one paged call, a failure remembered for the window with one[authz]line) is what bounds a stale answer when an event never arrives. A stale-ALLOW after a member leaves therefore lasts until the leave event (milliseconds) or at most the TTL; a stale-DENY is impossible becauseunknowndenies, and a new member's first read waits only for the join event or the TTL. A chat actor (resolveChatActor) does not carrymemberOfyet: a Slack user'sruns liststill lists the public channels, the granted ones and their own ([gap] below). Membership is a third way in, so it changes nothing for an actor the first way already admits everywhere: in an installation whoseaccess:*surface entry holdschannels: all, every browser session reads every channel by grant and none exercises the membership half — the relation is proven there by its unit rows alone, and observed live only once a browser session withoutallexists. Every run is stamped withchannelVisibilityat dispatch (dispatch()asksCoreDeps.channelDirectoryonce per run and writes the answer into theRunMetaand the record), so neither read-side membership nor the memory write gate needs a Slack call at read time; a record without the stamp isunknown→ 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 SlackC…channel →unknown, because onlyconversations.infocan tell a publicC…from a private one. The Slack one (src/channels/slackChannelDirectory.ts,SlackChannelDirectory, wired by the bot insrc/index.tsas soon as the Slack adapter is up) asksconversations.infofor aslack:C…orslack:G…id —is_imoris_mpim→dm(a group DM is private to its members like a DM, and its facts belong to the person, item 8),is_private→private, elsepublic— 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 answersunknownon 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). Aslack:D…id isdmfrom the id alone and never reaches the API (noim:readneeded); a non-Slack id is the static answer. The lookup needs the bot'schannels:read(publicC…) andgroups:read(privateC…/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 thanCHANNEL_DIRECTORY_TIMEOUT_MS(1.5 s — a coldconversations.infois one round trip; every later message in the channel is a cache hit) stampsunknownand the run proceeds, so a Slack outage costs a reply at most that long. Consequence: a run in a public Slack channel is stampedpublicand readable by every actor (member-of's public half) — a plain Slack user'sfriction reportnow 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, oris-selfonly; admins and operators (all) are unaffected. A machine token's pinned channel is the same relation:channels: {"http:ops"}seeshttp:ops(plus any run stampedpublic) and nothing else; a token with NO channel grant sees only public runs and its own.Memory — reflection writes are gated. For every candidate fact (and the summary), reflection resolves the extractor's
audienceto one of the run's scopes exactly as before (memory.md item 23) and then callsauthorize(runActor, "memory:write", memory-scope { key, kind, originChannelVisibility }), whereoriginChannelVisibilityis the run's stampedchannelVisibility(item 7) andrunActoris 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 themember-of/owner-ofrows 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 writeorg. That question: theorgrow carries the selectororiginVisibility: [public, machine], so a fact whose run originated in aprivateordmchannel — or whose run is unstamped /unknown(a directory failure, a timeout, a record from before the stamp) — has noorgrow and is narrowed, never widened, never silently dropped: admorigin narrowsorg→user(a DM is one person's conversation with the bot; its facts are that person's and travel with them), aprivateorunknownorigin narrowsorg→channel(the origin's own audience — exactly its participants), and either falls to the other when the run lacks the first;repois 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 itssupersedes: the record it would have retired lives in the scope the origin may not write, so that record stands.user,channel, andrepofacts stay where the extractor put them (the audience is a hint the policy narrows, never widens); a fact from apublicormachineorigin 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):memoryContextBlockderives its scopes from the request and never callsauthorize.Configuration — one
grantsshape and arestrictblock.grants: { <actor id>: { actions?: string[] | "all", channels?: string[] | "all", repos?: string[] | "all", codeRepos?: string[] | "all", organizations?: string[] | "all" } }keyed by platform-namespaced actor id — five configured axes (agents are actions, item 2); absent actions, channels, repos and organizations are empty; omittedcodeReposinherits explicitrepos; explicitcodeRepossets code access independently of repo memory ownership,"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 holdagent:run:<name>(orall), a listed repo is used only when the resolved actor's compiled code access covers it (case-insensitively): explicitcodeRepos, otherwise thereposfallback, plus additive human and surface grants; unlisted agents and code repos are open to human namespace actors; credentials and jobs always require their own grants, and restricting one thing never takes a grant from anyone.restrict.agentsmust name registered agents andrestrict.reposowner/nameslugs, or the load fails naming the offender.grantsFor(id)is a namespace baseline plus the id's entry: aslack:id holds the open chat commands (CHAT_OPEN_ACTIONS) andagent:run:<name>for every unrestricted agent, listed or not, and an entry ADDS to that ("exactly what it declares" is the rule for credentials); anaccess:<sub>browser session holds every registered group's read, personal writes and unrestricted agent actions, an entry adds;access:svc:,http:,mcp:credentials hold exactly their entry, unlistedNO_GRANTS; aschedule:id holds what the schedule registry declares for it (actor: { kind: "schedule", id: "schedule:<name>", grants }, no config list hand-nameshttp:cron) unless the block names it, then config wins whole;cli:localeverything. A key<ns>:*—slack:*,http:*,mcp:*,access:*— is a surface entry;mcp:personal:*is the narrower family for approved personal credentials: the same five axes, held by every actor that authenticated on that surface (access:*is every browser session, the org Cloudflare Access admits — never anaccess: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 a whole surface or the browser-approved MCP familymcp:personal:*: a partial subject (slack:U*) andschedule:*,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, and an id no surface owns inherits no*entry (fail-closed, item 10).config:write,repo:write, and everyruns:*are never a baseline.grantsandrestrictare the only authorization keys: any other top-level key (permissionsamong them) is unknown and fails the load by name, and an ingress token entry is exactly{ subject, channel?, email? }—emailbinds the credential to a person (item 15), and any other field (scopes, say) is ignored, so nothing in the token map can widen the grants entry.canRunAgentandcanUseRepocallauthorizeon the resolved actor. Code access uses explicitcodeReposwhen present, otherwise inheritsrepos, plus additive human baselines and surface entries; a code-only grant admits its matching restricted repo without memory ownership, while a credential withrepos: allandcodeRepos: []gets no code access from that entry; delegation intersects code and memory independently; it is compiled separately from the explicitreposownership axis used by repo memory; neither a credential bound to a person nor a delegation chain gains the person's open defaults. The other config gates read the same effective grants. Shared permission guidance (adminsHint) always says "an admin", never enumerates people or emits mentions, regardless of the grants table. This applies to config summaries, command errors, agent and repo refusals, onboarding guidance and Ship preflight. The baselines:Actor id Baseline (held listed or not) A grantsentry …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:write, steer:writeadds to it access:<sub>{ actions: every <group>:read + memory:write, mcp:write, steer:write + unrestricted agent actions }(the groups the store is handed at startup; the personal chat writes, including owner-checked steering, since the web chat, record 0043 — the tier rows still decide the target)adds to it access:svc:<cn>,http:<subject>,mcp:<subject>nothing ( NO_GRANTS)is exactly what it holds — dispatchincludedschedule:<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 reachesaccess:svc: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).unknownmembership or visibility is not a member. AchannelVisibility-less record isunknown. A schedule whose actor has no token sends nothing and recordsmisconfigured. A malformedscopesorgrantsentry skips the identity rather than widening it. AChannelDirectoryfailure yieldsunknown, neverallow. Anagentactor withoutonBehalfOfhas its own grants only; with one, never more than the principal.Resolved questions, stated as behavior. Admins hold
all-channels; an operator is membership-bound unless itschannelsis"all"— the fleet view is one grant away, never implicit.friction reportcomputes 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, anall-channelsholder's the fleet; the aggregate is per-actor by construction and a fleet view is theall-channelsgrant. Membership (item 7): the Slack directory supplies each channel's visibility fromconversations.info, so public channels are everyone's, and each linked person's channels fromusers.conversations, so a private channel is its members' too — for the dashboard and/api; for a chat actor a private channel is still its user's and its grantees' untilresolveChatActorcarriesmemberOf(the remaining[gap]). Channels are channels: an ingress token'schannelis where its dispatches are recorded, never a grant; a token reads no run (public runs and its own aside) until itsgrantsentry names channels orall; 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. Theself-improvementschedule actor is declared in the registry (scheduleActor("self-improvement", …)) with{ actions: {friction:read, friction:write}, channels: "all" }— the floorgrantsForserves forschedule:self-improvement(agrantsentry naming it replaces) — so the weekly pass analyzes every channel's runs. Until the shim hands firings to that actor, the dispatcher still seeshttp:cron, whosegrantsentry 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 holdagent:run:<name>— a surface entry can open it to everyone on that surface, and a denial still names the actor, never the surface key.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 holdstrace:read— a grant nobody holds by baseline — behind the same rule as/admin/crash.Reading another conversation (record 0037, amended). Automatic and explicit source reads share
referenceAccess. It asks theconversation:readrow for the resolved requester after fresh platform facts prove read-account access and the relevant native membership. Configuration grants cannot replace membership for private sources or member-only readers. A workspace read account may read public sources without joining; the requester must be a full member of the same workspace. Other shared-source readers still require native membership. Private sources outside the origin require a verified requester DM destination. A different shared destination refuses cross-channel context. Source receipts retain visibility and sharing; reuse and publication apply the same policy to the current destination.A request an app relays for a person is the app acting on the person's behalf (slack-channel.md item 13). When the Slack adapter resolves a bot-posted message to a person from message text — the
Sent by Claude in <#C…> on behalf of <@U…> · <permalink|thread>footer, read only from an app the operator named inslack.relayApps— theIncomingMessagenames the person asuserIdand the app aspostedBy(slack:bot:<bot_id>). Text is forgeable: the gate keeps an unlisted app from naming anyone, but the configured relay app's footer is still that app's claim. SoresolveChatActorreturns, for such a message, anagentactor with the app's id and the app's own grants (the Slack surface baseline, plus whatevergrantsnames that app id) andonBehalfOfthe person's actor;effectiveGrantsis the intersection (theagentrule above), so the run never holds more than the app and never more than the person,config:write(auser-only row) is out of reach, andprincipalOfis still the person foris-self. Identity and authority part here on purpose: the record, the label and the costs page attribute the run to the person; what the run may do is bounded by the app that posted it. A message the person posted themselves has nopostedByand resolves as before. The dispatch gates decide on that same actor. They did not always:canRunAgent,canUseRepo,canManageReposandcanEditChannelConfigtook an actor id and every call in the dispatch path passedmsg.userId, so a relayed message's agent gate, repository gate, GitHub write gate, router preset list and live-agent steer check all answered from the person's grants alone — the forgeable-text hole this item closes atauthorize()stood open at those gates. Now the four config gates takestring | Actorand, handed anActor, readeffectiveGrants(the intersection for anagent); every dispatch-path call passeschatActorOf(config, msg), andgithubCapabilityFortakes the actor. A relay for an admin therefore runs a restricted agent or writes a restricted repository only where config names the app too, and a plain user bounds a granted app as before.postedByrides whereverauthenticatedAsdoes (item 15) — a spawned child, a steer, a plan runner's units, the durable inbox row, the ledger row and the resume message — so a restart, a resume or a child decides the same way the first dispatch did. An installation with norestrictblock and no grants forrepo:write/config:writebeyond its admins sees one change from this: a relay'srepo onboardhint and channel-config awareness read as the app's, not the admin's.A credential bound to a person retains its credential limits. Adapters resolve identity from verified server data. The message names the person as
userIdand the credential asauthenticatedAs; both survive saved runs, steering, child runs and restarts. Records and costs name the requester, while gates usechatActorOf(config, msg). Linked identities remain aliases for own-run visibility. A caller cannot nominate another person's id.
MCP binds the immutable user selected during authenticated browser connection setup: the verified Slack person, or the authenticated browser account. The policy table must admit both this user and the credential, including native conditional personal rights and current restrictions. The credential cannot borrow a denied user right, and a read-only connection cannot write. An unverified declared person is refused. Config commands update that user's own scope. Code access, repository-memory ownership, private source membership and GitHub author bindings retain their separate checks.
HTTP ingress and the local CLI retain their existing boundRequester email resolution and credential policy. A missing or failed link leaves them unlinked. Dashboard chat retains the verified Access session as its credential and its linked person as requester. MCP's stricter proof does not make an unverified HTTP claim a principal or change relay authority (item 14).
- A confirmed command is authorized as the requester again, at the click (record 0044; routing-and-config.md item 25;
src/core/dispatch/confirm.ts). A click on a confirmation's affordance is a credential presented later, possibly by someone else, for a command a model chose — so two decisions are made, neither by the adapter. First the store decides whose click it is: the row's requester is the stored message'suserId(the person, on every surface — a bound credential's message names the person, item 15; a relayed message'suserIdis the person, item 14), and the consume passes only when that id is the clicker's ownidor in itsself(actorIdsOf; record 0042's identity link, never a grant), so a person who asked in Slack may confirm from the browser once the identity record links them, and a credential bound to nobody or to someone else is refused by name with the row kept. Then the command is authorized as it would be typed:dispatchClickhands the stored message and parsed input torunChatCommand, whose caller ischatActorOf(config, stored message)— the requester's actor, with itspostedByintersection or itsauthenticatedAsbinding exactly as at the sentence — and the policy table decides once more atinvoke, so a requester who lost the grant between the offer and the click gets the typed path's refusal, the clicker's own grants lend the run nothing, and the audit line carriessource: confirmbeside the requester's id. No new action, resource or row: the same table, asked once per request, against the resolved actor. 16a. A steer is authorized against the run it names — the steer owner rule (the one-door plan's admission unit;authorizeSteerOwner,src/core/dispatch/authorize.ts). A bind ofsteer(steer.run— the typed line, and the line the operator binds underrouting.operator: on) folds a person's words into a live run BY ID, so the command door alone cannot judge it: the command'ssteer:writeaction is every chat user's (CHAT_OPEN_ACTIONS) and is available to a machine credential with an explicit grant, and the wired sender (createSteerSender,src/core/dispatch/admission.ts) asks the owner rule against the NAMED run before anything is pushed: the author must be that run's requester — their own id or any id their identity record links (Actor.self, record 0042) — or hold theruns:writegrant; fail closed, a run with no requester on record admits nobody but a grant holder. A RUN authoring a steer (steerRun'sfrom) stands in for the plan's requester only where its own lineage names the target: its spawned child (send_to_run'sparentRunId), its own parent (the lineage tell), or a run of its own plan instance (parentInstanceId) — a run outside them is refusedsteer_outside_instancewith nothing pushed, and the borrowed requester's ids and grants lend the run's steer nothing; the stand-in reaches no destructive bind (every other bind the runner authors is authorized as the runner itself). Past the owner rule the fold issteerRun's, exactly as a thread reply's: the live agent's allowlist, the durable copy first, the slot matched by run id (thread-admission.md items 5 and 7). A steer into a run that ended is refused by name; its words are never re-dispatched as a new request. The caller's credential rides the steer whole: the sender readsauthenticatedAs(a bound credential's actor is the credential with the person asasUser) andpostedBy(a relay's actor is the apponBehalfOfthe person) back off the resolved actor and carries them on the fold, the durable row and the resumed run, so the steer's later life re-resolves the same actor items 14 and 15 promise — never the bare person. The plane's steer is the one steer no chat door sees (record 0064; record 0057 as amended): the run ledger object writes the checkpoint and reissue steers into a write-preset run's inbox itself, as the system actorplane(PLANE_ACTOR_ID, aserviceactor holdingPLANE_GRANTS' one standingsteer:write; the policy rowsteer:writeonrunforserviceactors records it) — the row's sender isplane, and the object holds no other authority. - An admin views as a person by borrowing their ceiling, read-only, with their own name on the line (record 0053). A dashboard session whose own actor holds
allmay view as a Slack person: the resolver builds the session's actor with the person hung under it —onBehalfOfthe actor the person's own dashboard session would resolve to (the browser baseline,self: [person],memberOffrom the directory),viewingAs: { id, name? }beside it — soeffectiveGrantsis the intersection (the person's),principalOfis the person, and everyis-selfandmember-ofread is the person's, whileidstays the admin's. Run and memory policy retain the borrowed ceiling. Organization sends and account linking also checkviewingAsand refuse while it is set; the audit line and banner keep the admin identity. The registry door refuses every command whose effect is notreadwhile it is set, before parse, with the one sentence ("You are viewing as Ivy; writes are your own to make — exit view-as to write.", the person's name or id), reasonviewingon the audit line, which carriesviewingAson every line of such a session; the/apiadapter's early refusal (the one that spares an unauthorized body) answers the same sentence, and the two writes outside the door (the stop route, the chat's send) refuse with it too. The carrier is thesb-view-ascookie (HttpOnly, SameSite=Strict, path/, a session cookie) set byPOST /runs/view-asand cleared byPOST /runs/view-as/exit, both admitted to a session holdingallalone; the gate reads it onto the identity and the resolver honours it under the same condition, so for any other session — a forged or stale cookie included — the actor is exactly what it is without one, its own person link intact. A service token never views as anyone. What view-as does not see: a config entry keyed to the person's own Accesssub. - The author binding is identity, with one trusted write (record 0062; routing-and-config.md item 30). A person's GitHub login and immutable numeric id live as ONE validated key in their user scope (
users.<id>.github), read at the sandbox seam to author their commits — identity, never authority: no policy rule, no gate and no grant reads the binding, soeffectiveGrants,resolveChatActorandchatActorOfanswer byte-identically with and without it. The one trusted write is a dedicated action,identity:write— one policy row,identity:writeonconfig-scope { user }withhas-grant(identity:write), held byalland by a namedgrantsentry and by NO baseline; it is deliberately not the channel-config right, because aconfig:writeholder could otherwise bind any unbound person to any login and author commits as them.config set user --user <id> --github <login>asks that row, resolves the login once (GET /users/<login>over the read credential,resolveLogininsrc/execution/authorBinding.ts) and stores the exact{ login, id }pair; a 404 is refused naming the login, andconfig clear user --user <id>(the same row) removes the binding alone. Reads resolve by the immutable id (GET /user/<id>,bindingOf): a stored id whose current login differs from the stored login is refused with one[identity]log line and yields no pair — a rename is never followed, because a freed login is claimable; the stored pair's ANSWER — the pair, or the rename/deleted-account refusal — is cached forAUTHOR_BINDING_TTL_MS(src/core/budgets.ts: the pairs are resolved per exec and the harness's log polls ride the same executor, so an uncached read would cost oneGET /user/<id>per command against the installation's shared rate limit), keyed on both halves so a rebind reads fresh at once, and the identity rewrite's read before the PR opens passesfresh: trueand bypasses it — a rename inside the window is still caught where it matters; a bare config.yaml login is resolved once per process and cached; no binding, a failed read (never cached — the next read asks again) or a mismatch is fail-closed — no pair. The refused self-write:config set me --githubis the one write that would make impersonation trivial, so the REGISTRY DOOR refuses it before parse and handler on every surface (CommandDef.door, the view-as refusal's shape, item 17) with one sentence — "your GitHub login is set by an identity admin; it is not yours to type" — and reasonidentityon the audit line;config clear mepreserves the key (clearUserOverride), so "reset my settings" never severs an identity admin's write.
Deliberate public PR images
PR-image publication reuses the requesting actor's existing repo:use decision through the GitHub capability's canWrite on the run's resolved repository, never a model-supplied repository. Only an explicit attach_file.publishForPr: true requests the effect; its source key is derived from that call in that run after thread delivery. The Worker publication endpoint requires the internal artifact-copy bearer, which never reaches the executor. Anonymous reads are restricted to marked public copies under the fixed-shape /pr-images/<UUID>.png address, not to any run or its private keys. No run-reader, git-push or bucket-publicity guard changes.
Organization and account linking
The fixed installation organization is a typed resource in the same policy table. A human's organization membership is a current fact proved by the authenticated ingress issuer; linking a person supplies no membership or grant. A service needs both the organization action and an explicit organizations scope. An absent scope is empty even when every older grant axis is all. Delegation requires each credential and principal to pass their own policy. Account linking admits only the authenticated user's exact actor id, without linked aliases, delegation or view-as.
Validation criteria
| Criterion | Evidence |
|---|---|
| Named Slack and Access humans remain selectable; wildcard permission patterns, services, loopback subjects and schedules cannot name a person. No grant or authority is changed by this selector. | [unit] src/core/authz/viewAs.test.ts::viewable person IDs::accepts named Slack and Access people without admitting wildcard patterns or nonhuman subjects |
| Organization scopes parse strictly, union only explicit entries, and admit a service only for the named organization and action | [unit] src/core/authz/grants.test.ts::organization grants::* |
| Current issuer-proved membership admits only that organization; linked identity grants nothing; credential and principal must both pass; missing scope fails closed; account linking is exact authenticated self only | [unit] src/core/authz/authorize.test.ts::organization and identity policy::* |
| Organization and identity predicates match point decisions, and an organization predicate cannot reveal a run | [unit] src/core/authz/predicate.test.ts::organization and identity predicates::*, src/core/runRecord.test.ts::run visibility filter — the wire form of an authz Predicate (authorization.md item 6)::refuses organization predicates at the run boundary |
| A verified user and credential must both be admitted by the same policy, including native conditional personal rights; a read-only delegate, restricted code repo, unowned repo memory and another person's private run remain refused | [unit] src/core/authz/personalDelegation.test.ts::verified personal delegation::* |
Item 18: the identity:write row on config-scope { user } admits an all holder and a named identity:write holder and denies a config:write holder, the person themself and a browser session; the action is never a baseline — absent from the chat and browser baselines and from every unlisted lookup, held by all and by a named entry | [unit] src/core/authz/policy.test.ts::POLICY coverage::*::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/grants.test.ts::identity:write is never a baseline (record 0062)::is absent from the chat baseline, the browser baseline and every unlisted lookup, ::is held by \all` and by a named grants entry` |
Item 18: config set me --github is refused at the registry door on every surface — Slack, the CLI, the dashboard chat, an admin included — with the one sentence and reason identity on the audit line, before the handler; the door seam refuses a matching raw input before parse with the definition's sentence and reason (decidedBy: "door", so the chat line is the sentence itself, never the restricted-admins template), and admits nothing else | [unit] src/core/commands/config.test.ts::config set user / config clear user — the author binding (record 0062)::\config set me --github…, src/core/commands/config.test.ts::config set user / config clear user — the author binding (record 0062)::the chat surface prints the door's own sentence…, src/core/commandRegistry.test.ts::CommandRegistry.invoke — auth before parse::a definition's door refusal (record 0062)…` |
Item 18: config set user by an identity:write holder resolves the login and stores { login, id }, a 404 refuses by name storing nothing, a config:write-only actor is refused by the table on set and clear, config clear me keeps the binding while dropping the rest, and config clear user removes the binding alone | [unit] src/core/commands/config.test.ts::config set user / config clear user — the author binding (record 0062)::\config set user --user…, ::a login GitHub answers 404 for is refused by name, and nothing is stored, ::a `config:write`-only actor is refused the user scope by the table, on set and on clear, ::`config clear me` removes the other overrides and keeps the binding, ::`config clear user --user…` |
Item 18: no grant reads the binding — chatActorOf and effectiveGrants are byte-identical with and without the key; reads resolve by id and a rename is refused with the [identity] line and no pair; a bare login is resolved once per process; a failed read yields no pair and is not cached | [unit] src/core/commands/config.test.ts::config set user / config clear user — the author binding (record 0062)::no grant reads the binding…, src/execution/authorBinding.test.ts::bindingOf — reads the stored binding only::*, src/execution/authorBinding.test.ts::resolveLogin / resolveById::* |
Item 16: a confirmation is consumed only for a clicker whose id or self holds the stored requester, and the command is authorized and run as that requester rather than borrowing the clicker's grants | [unit] src/core/dispatch/confirm.test.ts::consumeAndRun — the stored input runs once, as the requester, through the typed path (record 0044)::a clicker whose id and \self` miss the requester reads…, ::a clicker whose `self` holds the requester runs it from another surface's id…, ::the command is authorized as the requester again at the click…` |
Item 16a: the owner rule — the requester steers their own run (any linked id counts), a member without the grant is refused, runs:write admits, an orphan target admits nobody but a grant holder; the runner's stand-in reaches only its own lineage (instance, own child, own parent) and no destructive bind | [unit] src/core/dispatch/authorize.test.ts::authorizeSteerOwner — the steer owner rule (docs/reference/specs/authorization.md item 16a)::* |
Item 16a: the wired sender — a member's steer into the requester's run is refused with the reason and nothing pushed; the requester's folds into the named run's own thread; a grant admits a non-requester; an ended run refuses the words without a new request; a run's steer outside its lineage is refused before any push; a bound credential's authenticatedAs and a relay's postedBy ride the fold and the durable row | [unit] src/core/dispatch/admission.test.ts::createSteerSender — the wired sender behind…::*, src/core/dispatch/admission.test.ts::steerRun — the owner rule for a run's steer (authorization item 16a; the one-door plan's admission unit)::* |
Item 16a: the door — steer:write is every chat user's and never a token's baseline; the policy row needs the grant; a signed-in browser also holds it for its own owner-checked steer | [unit] src/core/authz/policy.test.ts::POLICY coverage::every row has a case and every case names a row (no duplicate rows) |
Item 15: an ingress entry's email parses lower-cased and trimmed, a value without an @ drops the field never the entry, a non-string skips the entry; the identity is exactly { subject, channel?, email? } | [unit] src/core/ingressTokens.test.ts::parseIngressTokenMap::keeps \email` lower-cased and trimmed when it looks like one; a string without an `@` drops the field, never the entry, ::an identity is exactly { subject, channel?, email? }: any other field in an entry (`scopes`, a typo) is ignored — nothing in the token map can widen what the grants entry says` |
Item 15: boundRequester names the person the lookup finds with the credential as authenticatedAs; no email, no lookup, no match, a non-Slack answer or a throwing lookup leave the credential alone, never authenticatedAs without a person | [unit] src/channels/requester.test.ts::boundRequester::* |
Item 15, HTTP and CLI: a bound message resolves to the credential's actor — kind, id, grants and effective grants identical to the unbound token's, no onBehalfOf — with self: [credential, person] and asUser; a person wider or narrower than the credential changes nothing; the CLI bound stays every-grant; a self-binding is the plain actor; chatActorOf hands the gates that actor, a relay's and a bound credential's alike | [unit] src/core/authz/actor.test.ts::resolveChatActor — a chat message's namespaced user id chooses the surface::a bound credential (authenticatedAs set) is the credential's actor — kind, id, grants untouched — with the person as self and asUser, ::chatActorOf: the actor every dispatch gate decides on — resolveChatActor over config's grants lookup, so a relay is app ∩ person and a bound credential is the credential |
| Item 15, HTTP: the agent gate refuses a bound message whose credential lacks the agent though the person it names holds everything, and admits one whose credential holds it though the person does not | [unit] src/core/dispatch/authorize.test.ts::authorizeAgent — the agent gate, against the resolved agent::a message from a credential bound to a person is gated by the credential's grants: naming an admin lends nothing, and a granted credential is not narrowed by an ungranted person |
Item 15: the HTTP ingress and the MCP dispatch tool send a bound token's request as the person with the credential as authenticatedAs, ask the lookup once with the entry's email, still gate dispatch on the credential; HTTP retains its unlinked fallback and MCP refuses an unverified declared person; an unbound token never asks | [unit] src/channels/http.test.ts::handleIngressRequest (transport gating + dispatch)::a token bound to a person by email → the message is the person's (userId, userName) and names the credential as authenticatedAs; the dispatch gate still asks about the credential, ::a bound token whose person cannot be found (no lookup, no match, a failing lookup) sends as the credential, exactly as an unbound one, src/channels/mcp.test.ts::handleMcpRequest — tools/call::a token bound to a verified person sends as that person; an unfound declared person is refused |
Item 15: authenticatedAs rides the record (validated, JSON round-trip, absent for a plain sender), the durable inbox row and the resume message, so a restart or resume dispatches under the credential | [unit] src/core/dispatch/record.test.ts::*::carries authenticatedAs when a bound credential sent the request, and the record still validates; a sender who is their own credential → no key, src/core/runLedger/inboxMessage.test.ts::durable inbox — staged references (record 0033)::a bound credential's \authenticatedAs` rides the row and reads back; a message without one reads back without the key, src/core/resumeLaunch.test.ts::the pure pieces::resumeMessage pins only the agent and identity: model and effort resolve from the configuration in force for the fresh segment` |
Item 15, live: a token whose entry names the maintainer's email dispatches a run whose row reads the maintainer's name with the http label, whose record carries userId: slack:U… and authenticatedAs: http:<subject>, which ?mine=1 lists and the costs page bills to them; the same request with the email removed from the entry reads as the subject | [agent] human-gated after deploy: receipt on the tracking issue |
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; error at the reply-sized cap, reason/summary at the card-sized one) | [unit] src/execution/residentText.test.ts::sanitizeResidentBody::*; src/execution/residentText.test.ts::sanitizeResidentBody — an error is a reply, a reason is a card note::*; 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` |
Item 14, the gates: handed an Actor, canRunAgent / canUseRepo / canManageRepos / canEditChannelConfig decide on its effective grants — an unlisted app relaying for an admin is refused a restricted agent and the write rights, admitted an open agent; a bound credential decides on its own grants | [unit] src/config.test.ts::permission gates::handed a resolved actor, the gates decide on its effective grants: a relay's app ∩ person, a bound credential's own |
Item 14, the dispatch path: the agent gate refuses a relayed message naming an admin a restricted agent (admits an open one), admits it when config names the app, and a plain user bounds a granted app; the GitHub write gate refuses a relay the restricted repository the admin could write; a relayed steer is refused the restricted live agent and an admitted steer carries postedBy on the durable row | [unit] src/core/dispatch/authorize.test.ts::authorizeAgent — the agent gate, against the resolved agent::a relayed message (postedBy an unlisted app) naming an admin is refused a restricted agent, and admitted an open one, src/core/dispatch/run.test.ts::githubCapabilityFor — the github_* tools' capability for one run::pairs the process's API with the requesting actor's per-repo write gate — a relay for an admin writes only where the app may too (item 14), src/core/dispatch/admission.test.ts::steerRun — a run steers a live run through the inbox a thread reply takes::a relayed sender (postedBy) naming an admin is refused the restricted live agent — the app bounds the person — and an admitted steer carries postedBy on the row and the item |
Item 14, the relay survives: postedBy rides the durable inbox row and the resume message | [unit] src/core/runLedger/inboxMessage.test.ts::durable inbox — staged references (record 0033)::a relayed message's \postedBy` rides the row and reads back; absent otherwise, src/core/resumeLaunch.test.ts::the pure pieces::resumeMessage pins only the agent and identity: model and effort resolve from the configuration in force for the fresh segment` |
Item 14: a chat message with postedBy resolves to an agent actor — the app's id and grants, onBehalfOf the person userId names — whose effective grants are the intersection (an admin named by forgeable text yields only the Slack baseline, never config:write, never the admin's channels; a plain user bounds it too; principalOf is the person); a message without postedBy resolves as before | [unit] src/core/authz/actor.test.ts::resolveChatActor — a chat message's namespaced user id chooses the surface::a relayed message (postedBy set) is an agent actor: the app's id and grants, on behalf of the person named — so an admin named by forgeable text still yields only the intersection |
| 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, src/core/authz/policy.test.ts::POLICY coverage::runs:read run [is-self]::allows its allow cases through this row and through the table, ::no row for the (action, resource) → deny no-rule |
| Item 13: the configured workspace reader can read an unjoined public source into a permitted unshared destination; without it, the fallback reader still requires bot membership | [unit] src/channels/slack/context.test.ts::Slack context adapter::reads a public source without adding the bot to its channel, src/channels/slack/readAccess.test.ts::workspace Slack read access::reads an unjoined public source and retains bot delivery with no per-user connection |
| Item 13: a private source outside its origin requires selected-reader access, fresh requester membership and a verified requester DM; failed membership or an unverified, public, private-channel or shared destination refuses, including for administrators | [unit] src/channels/slack/context.test.ts::Slack context adapter::reads a private source only into a verified requester DM and drops it after membership changes during fetch, src/channels/slack/readAccess.test.ts::workspace Slack read access::requires the requester's own private membership even when the read account has access, src/core/dispatch/references.test.ts::readReferences — the references step::a private thread is quotable from inside its own channel, and an admin pointing from elsewhere is denied like anyone |
| Item 13: native members can read a shared public source into a permitted destination through automatic and explicit intake, and reuse retains its sharing facts; a nonmember or a different shared destination refuses, including inherited context | [unit] src/channels/slack/context.test.ts::Slack context adapter::reads a shared public source through both entry points and retains its audience for reuse, ::does not read a shared source for a nonmember or export internal context to another shared channel, src/core/dispatch/contextAccess.test.ts::message-bound context access::checks the current destination before reusing a public source |
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 |
5: a child run is authorized as the requesting user — a conductor's child for a requester without agent:run:explore ends at the agent gate in its own thread and never reaches the factory, a read preset in a channel bounded to none ends at the profile gate, and the parent is told by the gate's name; the run tools' reads are the requester's: list_runs shows only what the predicate admits (a private-channel run is absent for a non-member, present for an admin) and get_run_status answers a run the requester may not read as not_found, like an unknown id | [unit] src/core/dispatcher.test.ts::agent:conductor — a run that spawns child runs through dispatch()::a child for a requester without…, ::a child whose preset needs \read` in a channel bounded to `none`…, src/tools/runs.test.ts::list_runs — the runs the requester may see::lists this run's live children by default…, src/tools/runs.test.ts::get_run_status — one run as the requester may see it::an unknown id and a run the requester may not read are both `not_found`…` |
2: coordinator:step admits a service actor holding the grant (the coordinator bearer's http:coordinator) and denies an admin's all, a token without it and the schedule actor; every coordinator route decides it through authorize before a body is read — 403 without the grant, 401 without a bearer, 503 without the map | [unit] src/core/authz/policy.test.ts::POLICY coverage::*::allows its allow cases through this row and through the table, ::*::denies its deny cases in this row and across the table, src/channels/adminCoordinator.test.ts::the coordinator routes — the bearer (item 9)::a bearer without coordinator:step is refused 403 on every route… |
2: plan:merge admits a service actor holding the grant (the same bearer, granted both) and denies the coordinator bearer without it, an admin's all, a token and the schedule actor; the names are the contract's | [unit] src/core/authz/policy.test.ts::POLICY coverage::*::allows its allow cases through this row and through the table, ::*::denies its deny cases in this row and across the table, src/core/coordinator/contract.test.ts::the coordinator's names::the identity is the \coordinator` ingress subject, the step action is `coordinator:step` and the merge action is `plan:merge`` |
predicateFor ⇔ authorize 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 |
Record 0042: is-self admits a run whose userId is any id in the actor's self — a linked dashboard session sees its person's private and DM runs, an unlinked one neither; the person's config and memory scopes are the linked session's too; acts-as-person admits the MCP self-serve command row for a Slack person (even with no grants) and a linked session, never an unlinked session or a credential; both linked and Access-only browser sessions pass the owner-stop command door (item 5a), but each can stop only its own runs; command decisions and effective grants remain equal | [unit] src/core/authz/authorize.test.ts::record 0042 — the self set is identity, not authority::*, src/core/authz/policy.test.ts::* (the mcp:write command [acts-as-person] kinds=user cases) |
Record 0042: is-self compiles to one user-is per self id, ORed — a linked session's runs predicate admits its person's private and DM runs, the unlinked session's is byte-identical to before, and no new predicate kind reaches a store | [unit] src/core/authz/predicate.test.ts::record 0042 — the self set in a store predicate::* |
ownedBy(actor) is the self set as user-is alternatives; allOf onto the readable predicate only narrows (the linked session keeps its person's private run and loses another's public one, the unlinked session and the fleet admin own nothing); allOf drops all, sinks on none, flattens | [unit] src/core/authz/predicate.test.ts::ownedBy — the "mine" filter is the self set, ANDed onto what the viewer may read::* |
predicateFor compiles all-channels → all, member-of → or(channels-in, visibility-in(["public"])) (the public half alone for an actor with no channel grants; the channels-in leaf is the grants ∪ memberOf), owner-of → repos-in, is-self → user-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, on the tokenless view route — 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 No run 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… and the web chat's web:<sub> lane → 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 org → channel (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: false → public, is_private: true → private, is_im / is_mpim → dm; 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 the person's own channel set's answer (true, false, or unknown when the set is) | [unit] src/channels/slackChannelDirectory.test.ts::SlackChannelDirectory.info — visibility from conversations.info::*, ::SlackChannelDirectory.isMember — the person's own channel set::* |
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 code repo is open through the human baseline, a restricted agent only to a holder of agent:run:<name>, and a restricted repo only to an actor whose compiled code access covers it (codeRepos when present, otherwise repos, with human/surface unions and delegation intersections); 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::*, 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 plus memory:write, mcp:write, steer:write and unrestricted agent actions, 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; canManageRepos / canEditChannelConfig answer from the table; an ingress token entry is exactly { subject, channel?, email? }, 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 always says "an admin" without naming people or surfaces | [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?, email? }…, 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 |
Item 4, the channelConfig read half: config show --channel and the instructions peek admit a public channel's scope from anywhere, a private, DM or unknown one only from inside it (no lookup) or by grant (the admin, a config:write holder, a credential's channel grants), a credential on config:read alone reads only a public one, and every refusal is the one unauthorized line that never carries the instructions; both config:read rows on config-scope { channel } have allow and deny cases, the pointing actor's included | [unit] src/core/commands/config.test.ts::config show --channel is bound by the target channel's visibility::*, src/core/authz/policy.test.ts::POLICY coverage::*::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/resource.test.ts::attributesOf::repo, config-scope, agent, command |
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 test | buildresolveagent { 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; unlinked 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":"no run 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. |
Item 17: the view-as actor's effective grants are the person's, the principal the person, the id the admin's; viewingAs preserves the run and memory decisions but refuses organization sends and account linking; over the run and memory fixtures the view-as actor lists exactly what the person's own actor lists for every read and write action, and rides every differential loop | [unit] src/core/authz/authorize.test.ts::record 0053 — viewing as a person borrows their ceiling::*, src/core/authz/predicate.test.ts::view-as ≡ the person (record 0053)::*, src/core/authz/predicate.test.ts::predicateFor ⇔ authorize differential over runs::runs:read::admin viewing as ivy (record 0053): predicate filter == point authorize |
Item 17: the resolver honours the cookie for a session holding all naming a Slack person — the person's browser-baseline grants, self and channels under the admin's id, named when the directory answers — and for nobody else: a narrower session, a non-person id, a service token resolve exactly as without it | [unit] src/channels/commandHttp.test.ts::callerIdFor — one Access identity → caller id mapping for /api and the audit line::resolveAccessActor with viewAs: a session holding all becomes the admin on behalf of the person — the person's browser-baseline grants, self and directory channels under the admin's id, viewingAs named; anyone else, or a non-person id, resolves exactly as without the cookie |
Item 17: the door refuses every non-read effect while viewing, one the person may make herself included, in the one sentence before parse, audits viewingAs and viewing; reads run under the person's grants with viewingAs on their line; the /api adapter's early refusal answers the same sentence before the body is read, never the grant refusal; the stop route (token path and tokenless) and the chat's send refuse with the same sentence, control untouched, nothing dispatched | [unit] src/channels/commandHttp.test.ts::createCommandHttpHandler — caller resolution::a session viewing as a person (record 0053) reads on runs.list and is refused POST runs.stop before the body is read with the view-as sentence, not the grant refusal; the same session without the cookie stops the run, src/core/commandRegistry.test.ts::CommandRegistry.invoke — auth before parse::a caller viewing as a person (record 0053) runs a read under the person's grants and is refused every non-read effect — even one the person's own grants allow — in the one sentence, decided by the registry before parse, with the audit line naming the person and the reason viewing, src/channels/liveView.test.ts::run control: POST /runs/:id/stop::a viewer viewing as a person is refused the token-path stop with 403 and the one sentence, and the run's control is never touched, src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle::persisted run events, friction and stop::the tokenless stop is refused while viewing as a person (403, the one sentence) before any run is consulted — a persisted run, a live one and an unknown id alike, src/channels/web.test.ts::POST /threads/<id>/send — the body into dispatch() as this session (item 11)::a session viewing as a person is refused a send with 403 and the one sentence; nothing is dispatched |
Item 17: the routes — POST /runs/view-as sets the cookie for a session holding all (204, HttpOnly, SameSite=Strict, Path=/, Secure on https) and refuses anyone else before the body is read; a body naming no Slack person is 400, a foreign origin 403, GET 405; POST /runs/view-as/exit clears it; the parser reads the cookie back by name and nothing else | [unit] src/channels/viewAs.test.ts::viewAsFromCookie::*, src/channels/viewAs.test.ts::the view-as routes::*, src/channels/viewAs.test.ts::refuseWhileViewing::* |
Item 17, human-gated: on the deployed installation the maintainer picks a person who is in a private channel on /runs, sees that channel's runs and no DM of their own, sees the banner on every page, is refused a Remove on /settings/mcps and a Stop with the sentence, and Exit returns their own page | [agent] receipt on the PR and on the authorization receipts ledger |
Credential scope proof
| Criterion | Evidence |
|---|---|
Explicit codeRepos grants code access independently of repository memory ownership; omitted codeRepos inherits the existing repos grants; complements in config are rejected. | [unit] src/core/authz/scopedCredentialPolicy.test.ts::compiled credential authority::* |
| Resolved service credentials run only granted agents and use only granted code repos, even when globally unrestricted; bound people, relays and nested delegation never widen that authority. Human open defaults preserve explicit memory ownership. | [unit] src/core/authz/scopedCredentialPolicy.test.ts::compiled credential authority::* |
| Code repo access compiles to the same point, predicate and serialized filter decisions, including case normalization and missing repos; complements are bounded and unknown filter kinds fail closed. | [unit] src/core/authz/scopedCredentialPolicy.test.ts::compiled credential authority::*, deploy/cloudflare-memory/runs.test.ts::repo complement filters match point and wire semantics in SQLite and reject malformed complements |
Roadmap (gaps)
| Criterion | Evidence |
|---|---|
Membership on the actor (item 4): member-of admits another person's run in a private channel the actor's memberOf names and the channel's config scope, denies without memberOf and for a channel outside the set; memberOf grants nothing (effective grants and every command row answer the same with and without it; in the channel is still not runs:write); it is the root principal's, read through onBehalfOf like self | [unit] src/core/authz/authorize.test.ts::issue 516 — memberOf is a directory fact beside the grants::* |
Membership in the store predicate (item 6): the member browser's runs:read predicate lists every private-channel run and no other person's DM run, the unlinked browser none of the private ones; the widening is one channels-in leaf; the differential holds for the member browser over runs and memory scopes | [unit] src/core/authz/predicate.test.ts::predicateFor ⇔ authorize differential over runs::memberOf widens channels-in with the person's channels and nothing else: the member browser lists every private-channel run, the unlinked browser none of them, ::linked browser whose person is in the private channel (memberOf): predicate filter == point authorize |
The Slack directory's membership (item 7): channelsOf(slack:U…) lists the person's public and private channels as slack: ids, paged by cursor, once per person per TTL; concurrent first asks share one call; a failure is unknown for the TTL with one [authz] line; a non-Slack actor, a bot or an app id never reaches the API; forgetMember re-asks for that person only, forgetAll for everyone; past the page cap is unknown, never a partial set; the cache is bounded; isMember is the set's answer | [unit] src/channels/slackChannelDirectory.test.ts::SlackChannelDirectory.channelsOf — a person's channels from users.conversations::*, ::SlackChannelDirectory.isMember — the person's own channel set::* |
The resolver (item 7): a linked dashboard session carries the person's channels as memberOf, asked for the person's id; no lookup, unknown, a failing lookup, a lookup slower than the bound or an unlinked session carry none and stay linked; a late rejection is swallowed; grants never change | [unit] src/channels/commandHttp.test.ts::callerIdFor — one Access identity → caller id mapping for /api and the audit line::resolveAccessActor carries the linked person's channels as memberOf; no lookup, unknown, a failing lookup or an unlinked session carry none, and grants never change, ::resolveAccessActor bounds the channels wait: a lookup slower than the bound resolves the linked actor without memberOf, and its late rejection is swallowed |
Human-gated (item 7): on a deployed installation whose Slack app carries the manifest's membership events, a non-admin who is in a private channel (or an admin viewing as one, item 17) opens /runs and sees another person's run from that channel, and /settings/mcps lists that channel's tier; after leaving the channel the run is gone from the index without waiting for the TTL | [agent] As a linked non-admin in a private channel the bot is in: read /runs and /settings/mcps, have another member post a run there, reload; leave the channel, reload within a minute. Receipt on the PR. |
Chat actors carry memberOf: a Slack user's runs list lists the private channels they are in | [gap] resolveChatActor is synchronous and consults no directory; the dashboard and /api carry membership, chat lists public, granted and own |
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… |
| Shared permission guidance never lists or mentions administrators, including with multiple named admins or a whole-surface grant | [unit] src/config.test.ts::grantsFor — the grants the policy table decides on::adminsHint stays generic regardless of named or surface admin grants, src/channels/slackActorMentions.test.ts::shared permission guidance::config summaries and permission refusals never mention the admin roster |
| runs.get include=audience uses the existing run-read authorization. Denied and nonexistent runs have the same response; default reads omit diagnostics. | [unit] src/core/commands/runs.test.ts::channel visibility (authorization.md items 5–7)::authorizes audience diagnostics through the same run read and hides denied run existence |
| Requester cancellation (item 5a): verified bound owner can stop through MCP form consent without broad writes; target/connection changes and foreign runs refuse; stop is requested, not reported ended | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::a verified requester stops only their own review through MCP form consent without runs:write |
| Requester cancellation refuses unlinked/read-only/spoofed credentials, rechecks authority after consent, and preserves repeat and terminal behavior | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::MCP self cancellation refuses unlinked, read-only and spoofed callers and rechecks consent authority, ::MCP owner stop preserves confirmation, monotonic repeats and terminal conflict, src/core/authz/policy.test.ts::requester stop authorization::* |
| Access-only personal MCP cancellation uses its stored verified account through normal form consent; forged caller identity and another account’s run refuse; other identity policies remain unchanged | [unit] src/channels/mcpApprovals.test.ts::MCP approval parity::a verified Access-only personal MCP account cancels its own run without accepting forged account prefixes, src/core/authz/policy.test.ts::requester stop authorization::Access accounts are cancellation-only identity facts, not service prefixes or unrelated write grants |
| Read projections preserve the relay app separately from the credited person and credential; relay display metadata grants no new authority and does not change owner filtering | [unit] src/core/runsService.test.ts::RunsService.getRun::preserves relay attribution beside the person and credential in live, ledger and saved views |