Space-scoped agent capabilities, and capabilities in private spaces #
Implements the plan in full. Every step leaves pnpm typecheck && pnpm lint && pnpm test green;
the final gate was run on the committed tree.
1. The record: an optional scopes overlay #
com.disnetdev.radial.agent gains scopes?: [{space, artifactTypes}] (#scope def, at-uri +
string array, maxLength 50/100). Additive: nothing became required, so every agent record
already in a repo stays valid and behaves exactly as before. pnpm codegen regenerated
packages/core/src/generated/records.ts (AgentScope, AgentRecord.scopes?).
Semantics: override, not intersection. In space S a profile's effective types are the entry
naming S if one exists, else the top-level artifactTypes. An empty list is a statement — "this
profile does nothing here" — and is the one thing artifactTypes cannot say, since it must be
non-empty. Entries naming other spaces are inert. One record per profile, same rkey, same
sanctioned-republish rule, same --update guard. The digest reads the registry rather than agent
capabilities, so it is untouched: no protocol version bump.
2. One resolver per side #
agentTypesFor(record, spaceUri)incore/src/records.ts— the fold-side rule, with unit coverage inmaterializer.test.mjsover agoldenScenario()agent that now carries a scope for the scenario space (narrowingreview, adrtoreview) and one for a foreign space. Asserted under the existing 200-permutation shuffle.actorTypesFor(actor, spaceUri)/actorAcceptsindaemon/src/actors.ts— the config-side twin, plusactorEverAcceptsfor the two genuinely daemon-wide questions (which identity signs a tangled push; whether a missing forge is worth warning about).
index.agents keeps its shape — the fold never rewrites a signed value — and every consumer switched
from agent.value.artifactTypes to the helper. No direct reader is left in any src/.
3. Config: agents.<profile>.spaces #
Parsed and validated in config.ts: keys must be at:// URIs, values must carry an artifactTypes
string array (empty allowed). A scope naming a space this instance does not serve is a warning
(unservedScopes, reported through loadActors), never an error — another instance sharing the
config may serve it. resolveAgentInits turns the block into the record's scopes, dropping
private spaces (see §5).
4. Space-aware selection #
ActorRegistry.select(did, type, spaceUri) in both implementations, and the eligibility filters in
claims.ts, dispatch.ts, auto-review.ts (eligibleReviewActors) and merge-poll.ts
(selectWritingActor) route through actorAccepts. loadedReviewActors deliberately stays broad —
it is a probe order, not eligibility, and missing an actor's record writes a duplicate. Dispatch's
rejection reason now says "in this space".
Behavioural tests: a plan-only-elsewhere profile does not claim a plan here but does next door; an
assigned request whose assignee is scoped out is left alone (with the reason); a reviewer scoped out
drops out of eligibleReviewActors; a profile scoped out of implementations is not this space's
merge writer. The four test-file fake registries were updated to apply the real rule rather than a
simpler one.
5. Private spaces: the daemon publishes capabilities into the replica #
This is the thread's bug. agent is not on the public path (core/src/devices.ts, correctly — a
capability anybody with repo custody could mint is not one the space granted), so radiald init's
PDS write was invisible: index.agents was empty in every private space, the uncovered-types
advisory fired at every startup, and radiald init <profile> --update could not have helped.
reconcileCapabilities (private-space.ts) publishes each profile's record into the replica through
the already-sanctioned EnvelopeWriter.create/put:
- effective types for that space resolved into
artifactTypes, noscopesfield — a replica is per space, so the scoping vocabulary never travels in an envelope; - rkey = profile name, as on the public path;
- written only when it differs. The comparison (
agentRecordComparable) sorts and omitscreatedAt, because an unstable one would seal an envelope every tick that every peer would then keep.
Called from openConfiguredSpace before the first fold (so the advisory never fires on a space
the daemon is about to cover itself, after pollDirectory() so the daemon's own device is
verifiable), and again through refreshCapabilities() on the address-refresh interval — the retry
for a replica that had not yet read this daemon's device record back and so refused its own first
envelope. Per-profile failures are non-fatal: logged at startup, warned on later passes.
The public record correspondingly omits private-space entries — not because the URI is secret (a
join bookmark is public and carries it) but because the private fold ignores that source by
construction, so an entry there would be permanently unread and permanently stale-looking.
Coverage advisories now read differently for a private space: edit the config and restart, rather than a command that writes to a PDS.
Integration coverage over MemoryPrivateBus/loopbackLink in private-dispatch.test.mjs: fresh
replica → publish → index.agents non-empty → advisory silent; a second pass writes no envelope; a
config change republishes under the same rkey and the peer daemon folds the latest revision; a scoped
profile seals the effective list. privateScenario() gained an enveloped agent record that folds and
a public-path one that is refused, shuffled by private-fold.test.mjs.
6. Fold readers #
ui/src/lib/directory.ts unions the effective lists across a DID's records, so every surface
over Actor.artifactTypes (assignee picker, reply picker, review-capable check, admin) is
space-scoped with no change of its own. Vitest pure coverage for the picker. coverageAdvisories
compares effective lists on both sides.
7. Docs #
docs/design.md §4 (lexicon sketch), §11 (scoping semantics, old-reader degradation, the disclosure
note, the private-space carrier) and §14 (rejected alternatives D: per-(profile, space) records; an
admin-written space-side overlay); packages/lexicons/README.md (writer obligations for scopes);
docs/radial-json.md ("One profile, different capabilities per space"); docs/operators.md §2;
docs/running-an-agent.md; packages/daemon/README.md; packages/ui/README.md; CLAUDE.md.
Checks #
pnpm codegen (generated file committed and current), pnpm typecheck, pnpm lint, pnpm test —
all green: core 385, atproto 34, ingest 42, sidecar 110, daemon 588, transport-iroh 6,
transport-iroh-wasm 13, ui 68 + 49 vitest files, 0 failures. No new dependencies, no new packages,
both CI workflow files untouched. The auto-review source invariant test (exactly one
create(COLLECTIONS.artifactRequest) under src/) stays green.
Remaining risks #
- Old-reader drift is accepted and documented. A materializer that does not know
scopesshows the global list everywhere: a narrowed scope makes an old assignee menu over-offer (the request sits open — the existing uncovered-type failure the advisory explains), a widened one makes it under-offer while open requests are still claimed from config. - An assigned request outside a profile's scope is left silently, exactly as a request whose type nobody accepts is. Answering it with a message was noted in the plan as a follow-up and is not done here.
actorEverAcceptsis a union over the config, so a profile whose global list hasimplementationbut whose every scope drops it still counts as an implementer for the two daemon-wide questions (forge writer identity, forge advisory). Strictly better than the previous global-list-only reading, and neither decision is per space.- The private reconcile writes for every actor in the private registry, including one whose membership has not folded yet; the envelope sits in the store until the grant binds it, as any early envelope does.