ADR-045 — Machine × Agent Resource Scope
- Status: Accepted
- Spec: 010-sync (amended; also amends 001-mcp-gateway, 004-agent-registry, 005-skill-manager, and 009-channels)
- Amends: ADR-043 (widens the "per-resource runtime affinity" follow-up ADR-043 reserved alongside machine identity into a framework-level facility, generalized with an agent axis)
Context
Multi-machine sync (ADR-043) converges every resource onto every machine, but some resources are only usable on one machine or by one agent: an MCP server whose binary exists only on the MacBook, a skill written for Claude Code only. The vault has no general way to say so:
- Channels are the only kind with machine affinity (
runs_on, ADR-043). - Skills have per-agent delivery policy (consumer-side follow flag + exclusions) but no machine dimension.
- MCP servers have neither: the gateway exposes every server's tools to every agent on every machine, and out-of-place servers fail or add noise.
- Agents not installed on a machine are handled implicitly by the import gate — permanent quarantine noise, retried every run.
ADR-043 explicitly reserved "per-resource runtime affinity" as a follow-up hanging off machine identity. This ADR is that follow-up, widened to an agent axis, validated in a brainstorming session on 2026-07-10 and recorded in docs/research/machine-agent-scope.md.
Decision
Add one framework-owned scope field — a per-machine × per-agent activation matrix — to the resource model; each kind declares which axes apply and owns its own enforcement point.
Framework-level
scopematrix. Onescopeshape lives on theResourceentity, not inside kind config. Semantics (single source of truth for the implementation):scope == None → active on every machine, for every agent scope == {} → active nowhere (dormant) scope == {"<ulid>": ["claude-code"], "*": "*"} # dict[str, list[str] | "*"]- Entry lookup for machine M: exact-ULID key wins over
"*"key; no key → inactive on M. machine_in_scope(scope, m)→ entry exists and value is"*"or a non-empty list.agent_in_scope(scope, m, agent)→ entry value is"*", oragent(a name string) is in the list.agent=None(unidentified session) matches ONLY"*"values.scope=None→ always True.- Kind axes:
mcp_server(machine, agent) ·skill(machine, agent) ·agent(machine) ·channel(machine) ·knowledge_base,memory— none (non-null scope rejected). - Machine-only kinds accept only
"*"as entry value. - Unknown machine ULIDs / agent names in entries are legal and simply never match.
- Matrices referencing an agent are additionally intersected with that agent's own machine axis when computing activation slices (the Machines view); the gateway itself trusts the local shim's identity and does not re-check the agent's machine axis, since a local shim can only run where the agent is installed.
- Entry lookup for machine M: exact-ULID key wins over
Sync-but-inactive. A scoped resource still syncs to and is visible on every machine; out of scope it is simply not activated (not spawned, not exposed, not delivered). The registry stays the single source of truth and scope is editable from any machine — consistent with channel
runs_ontoday.Per-kind axes and enforcement seams. Each kind consults scope at its existing choke point, not a new central gate:
Kind Axes Enforcement seam mcp_servermachine × agent Machine axis: upstream not spawned, tools not listed on that machine (gateway _enabled_mcp_servers+ supervisor spawn gate). Agent axis: gateway filters the server's tools per session identity.skillmachine × agent Delivery filters by the matrix intersected with the existing per-agent follow policy; out-of-scope delivered copies are reconciled away. agentmachine No projection / reconcile / shim install on out-of-scope machines; the import gate stays as the fallback for in-scope machines missing config_dir.channelmachine runs_onmigrates into scope; the channel runtime consults scope instead.knowledge_base,memorynone A non-null scope is rejected at validation. Shim self-reported
--agentidentity. The shim install writescoffer-mcp-shim --agent <name>into the agent's config; the shim reports the name at handshake alongside the existing cwd_metainjection. Sessions without an identity (hand-configured shims) see only servers whose agent axis is"*"for the local machine. Trust boundary: identity is self-reported by the shim process, not cryptographically verified — acceptable in the single-user, loopback-only posture; the spec amendments state this boundary explicitly rather than implying stronger isolation than exists.Skill scope ∩ follow policy. Follow is agent-side intent ("deliver skills to me"); scope is resource-side grant ("this skill may run here"). Delivery is the intersection of both — scoped-in and followed, minus manual exclusions. Scope is a hard grant that overrides manual bindings: an out-of-scope skill is reclaimed even if it was previously delivered by hand.
Channel
runs_on→ scope migration.runs_on: <id>becomes{"<id>": "*"};runs_on: nullbecomes{}. A data migration converts existing channel resources on upgrade. Theruns_onfield is not removed from the schema — old payloads and synced docs from not-yet-upgraded machines must still validate — but it becomes inert: the channel runtime reads scope only, andruns_onis documented as deprecated in place.Knowledge and memory never scope.
knowledge_baseandmemorydeclare no axes and reject a non-nullscope— always shared across all machines and all agents. Chat history, audit logs, runtime state, and machine-local settings stay machine-local (unchanged; restated here as a boundary, not a new decision).Machines fleet view. A new top-level
Machinesnav item lists every registered machine (a sync-status strip above one card per machine); a per-machine detail view renders that machine's activation slice (agents present, MCP servers active, skills delivered per agent, channels bound), computed locally from the synced registry plus scope so any machine can render any machine's slice. Sync configuration (remote, auto-sync, master key) stays in Settings → Sync — this view is about activation, not transport.Manifest
SCHEMA_VERSION→ 4.scoperides the resource doc through the existing export → merge → import pipeline, auto-conflict resolution, tombstones, and quarantine, unmodified — zero new sync machinery. Adding the field to resource docs bumps the workspace manifest schema version from 3 to 4, so a not-yet-upgraded build fails closed with the existingSyncWorkspaceTooNewgate instead of silently dropping or misreading the field.
Alternatives considered
- Per-kind bespoke fields — let each kind grow its own affinity field (channel already has
runs_on; give MCP servers and skills their own). Rejected: three incompatible shapes to validate, document, migrate, and render in the UI, for one concept. A framework-level field with per-kind axis declarations gets the same expressiveness from one implementation. - Consumer-side selection — let each agent's local config decide which resources it uses (an override, not a registry field), mirroring how per-machine JSON-Merge-Patch overrides already handle divergent config values. Rejected: overrides are local by design and never sync as intent, so another machine could never see or edit a resource's activation — the registry would stop being the single source of truth for "where is this supposed to run."
Consequences
- Migrations 0046 (adds
resources.scope_json) and 0047 (channelruns_on→ scope data migration) ship with this ADR's implementation; workspace manifest v4 means all machines must upgrade before scope-bearing docs sync cleanly — the same fleet-upgrade requirement ADR-043 introduced for v2. - New REST surface (
GET/PUT .../scope,GET /api/v1/machines/{id}/slice) and CLI surface (coffer scope show|set|clear, top-levelcoffer machines) for a concept that previously had no direct surface (channelruns_onwas buried in channel config). - The gateway, skill delivery, agent import/reconcile, and channel runtime each gain a scope-aware branch at their existing enforcement seam — no new seam is introduced, but four call sites change behavior.
- Self-reported shim identity is a trust boundary, not an isolation boundary: any process able to write
_metaon the loopback MCP connection can claim any agent name. Accepted for a single-user tool; documented so a later multi-tenant posture doesn't inherit the assumption silently. - Out-of-scope resources are a badge and list filter, never an error state; gateway and delivery skip silently — consistent with today's
runs_onbehavior, now generalized. - Tool-level per-agent filtering (tool preferences) and machine-registry entry deletion remain out of scope for this ADR; no change to what syncs versus what stays machine-local beyond restating the existing boundary.