From d434583e2ba01ba52cb0b943b15e7454271eadc1 Mon Sep 17 00:00:00 2001 From: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) Date: Thu, 06 Aug 2026 22:29:35 +0000 Subject: [PATCH] Scope agent capabilities per space, and publish them in private spaces An agent record carried one global `artifactTypes`, and membership was all that bound it to a space — so a profile offered the same capabilities everywhere it was a member. `com.disnetdev.radial.agent` gains an optional `scopes` overlay (`{space, artifactTypes}`), read as an OVERRIDE of the global list: the entry naming a space is the whole answer there, an empty list says "nothing here", and no entry means the global list — which is every record already in a repo, so this is additive and needs no protocol version bump. One resolver each side (`agentTypesFor` in core, `actorTypesFor` in the daemon) is what the assignee picker, dispatch, claims, auto-review, merge annotations and the coverage advisories all read; `ActorRegistry.select` takes the space URI for the same reason. `agents..spaces` is the config half, and `radiald init --update` publishes it, treating a changed scope as drift like any other field. The second half is the thread's bug: private spaces had no agent capabilities at all. A private fold reads `agent` only out of an envelope, so `radiald init` wrote it to the one place that fold never reads — `index.agents` was empty, the uncovered-types advisory fired at every startup, and the fix it named could not have helped. The running daemon now reconciles each profile's record into the replica (`reconcileCapabilities`): the effective list for that space resolved into `artifactTypes` with no `scopes` field, keyed by the profile name, written only when it differs — at startup before the first fold, and retried on the address-refresh interval. The advisory's wording follows: for a private space it says restart the daemon rather than run a command that writes to a PDS. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- CLAUDE.md | 11 +++++++++++ docs/design.md | 10 +++++++++- docs/operators.md | 17 +++++++++++++++++ docs/radial-json.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/running-an-agent.md | 11 ++++++++++- packages/daemon/README.md | 13 +++++++++++++ packages/lexicons/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ packages/ui/README.md | 2 +- packages/core/src/records.ts | 22 ++++++++++++++++++++++ packages/core/test/materializer.test.mjs | 31 +++++++++++++++++++++++++++++++ packages/core/test/private-fold.test.mjs | 18 ++++++++++++++++++ packages/core/test/scenario.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- packages/daemon/src/actors.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- packages/daemon/src/auto-review.ts | 13 ++++++++++--- packages/daemon/src/claims.ts | 8 +++++--- packages/daemon/src/cli.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++------------ packages/daemon/src/config.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/src/dispatch.ts | 17 +++++++++++++---- packages/daemon/src/init.ts | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- packages/daemon/src/merge-poll.ts | 13 +++++++++---- packages/daemon/src/private-run.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- packages/daemon/src/private-space.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- packages/daemon/test/auto-review.test.mjs | 27 +++++++++++++++++++++++++-- packages/daemon/test/claims.test.mjs | 33 +++++++++++++++++++++++++++++++-- packages/daemon/test/config.test.mjs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/test/dispatch.test.mjs | 35 ++++++++++++++++++++++++++++++++--- packages/daemon/test/init.test.mjs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/daemon/test/merge-poll.test.mjs | 22 +++++++++++++++++++--- packages/daemon/test/private-dispatch.test.mjs | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- packages/lexicons/lexicons/com.disnetdev.radial.agent.json | 9 +++++++++ packages/core/src/generated/records.ts | 32 ++++++++++++++++++++++++++++++++ packages/ui/src/lib/directory.ts | 19 +++++++++++++++---- packages/ui/src/lib/requests.test.ts | 25 +++++++++++++++++++++++++ packages/ui/src/lib/requests.ts | 4 ++++ 34 file(s) changed, 1234 insertion(s)(+), 66 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,6 +307,17 @@ edits are flagged as edit annotations, not adopted. The sanctioned exceptions are claim renewal, an agent profile's republish, and — added with private mode — a `deviceAddress` republish, which is safe only because address hints are excluded from the device fold by construction (design §18). +- **An agent's capabilities are a per-space question, asked through one resolver.** An `agent` record + carries an optional `scopes` overlay (`{space, artifactTypes}`), and the rule is OVERRIDE, not + intersection: the entry naming this space replaces the top-level list, an empty list means "nothing + here", and no entry means the top-level list (design §11). Never read `agent.value.artifactTypes` + directly — `agentTypesFor(record, spaceUri)` in `core` is the fold-side resolver and + `actorTypesFor(actor, spaceUri)` in `daemon/src/actors.ts` the config-side twin; the two must agree, + and `ActorRegistry.select` takes the space URI for that reason. In a PRIVATE space the carrier is an + envelope and the publisher is the running daemon (`reconcileCapabilities`), not `radiald init`: the + private fold reads `agent` only off the private path, so a PDS write there is invisible by + construction. That record carries the effective list resolved into `artifactTypes` and no `scopes` + — a replica is per space. - **The daemon never authors an `answer` request.** Auto-review is the only daemon-authored hop. `daemon/test/auto-review.test.mjs` asserts this over the source: exactly one `create(COLLECTIONS.artifactRequest)` exists under `src/`, and it builds a `review`. diff --git a/docs/design.md b/docs/design.md --- a/docs/design.md +++ b/docs/design.md @@ -95,7 +95,9 @@ re?: strongref, — request this message responds to (§9) declines?: bool, model?} — with re, from the assignee: declines it com.disnetdev.radial.agent {handleName, harness, models: [{name, costHint}], - artifactTypes: [string]} — registry names + "review"/"answer" + artifactTypes: [string], — registry names + "review"/"answer" + scopes?: [{space, artifactTypes}]} — per-space OVERRIDE of the line above + (§11); no entry = the global list com.disnetdev.radial.image {blob: blob, alt?, name?} — a picture a body refers to; presentation only, and no part of the fold reads it com.disnetdev.radial.setGuestComments{space, enabled} — does this space ASK non-members for @@ -268,6 +270,10 @@ - An operator's daemon publishes one `com.disnetdev.radial.agent` record **per profile**, keyed by profile name in the agent's repo: harness, models, cost hints, and the artifact types it produces (registry names plus the built-ins `review` and `answer`). A single agent DID may publish many, so capability sets are added and retired by writing and deleting records rather than by provisioning accounts (§3). Republishing one under the same rkey — what `radiald init --update` does when a profile gains a type — is a sanctioned in-place edit, adopted latest-revision-wins by every materializer (§4). - Spaces admit agents via membership like anyone else. Requests + profiles + claims determine who does what; there is no central assignment. +- **Capabilities are per space, through an optional `scopes` overlay on the same record.** An agent record carries no space reference, so membership is what binds it to a space (§6) — which meant one capability set applied everywhere the agent was a member. `scopes` is a list of `{space, artifactTypes}` on the record: in space S, a profile's effective types are the entry naming S if one exists, **else** the top-level `artifactTypes`. It is an **override, not an intersection** — an entry is the whole answer for that space, and an entry with an empty list is a valid statement ("this profile does nothing here") rather than a missing one. Entries naming other spaces are inert. One resolver (`agentTypesFor` in `core`, mirrored config-side by `actorTypesFor`) is what every reader goes through: the assignee picker, the daemon's dispatch, claim and auto-review filters, and the coverage advisories. Nothing about the record's identity moves — one record per profile, keyed by profile name, republished under the same rkey — so §4's sanctioned in-place edit and the `--update` drift guard carry over unchanged, and the digest is untouched (it reads the registry, not capabilities): **no protocol version bump**. + - *Old-reader degradation is benign and already named.* A materializer that does not know about `scopes` shows the top-level list everywhere. If a scope narrows, an old assignee menu over-offers and a request assigned there sits open — exactly the uncovered-type failure the coverage advisory already exists to explain. If a scope widens, an old menu under-offers, while open requests are still claimed, because the daemon dispatches from its own config. The obligation therefore lives on the writers, as it does for `artifact.title` (`packages/lexicons/README.md`). + - *This discloses nothing new.* A `scopes` list names the public space URIs one agent differentiates between; grants and `join` bookmarks already make membership public. Stated here so it is a decision rather than an accident. + - *The carrier in a private space is an envelope, and the publisher is the running daemon* (§18). A private fold reads `agent` only off the private path, so `radiald init` — which writes to a PDS — cannot give a private space an assignee list at all. The daemon reconciles each profile's record into the replica instead (create, or republish under the same rkey when the config moved, and nothing at all when it did not), with the effective types for that space **resolved into `artifactTypes` and no `scopes` field**: a replica is per space, so the scoping vocabulary has nothing to say inside one. `resolveAgentInits` correspondingly drops private spaces from the public record's `scopes` — not because the URI is secret, but because the public record is a source that fold ignores by construction, so an entry there would be permanently unread and permanently unable to look anything but stale. - **Harness is per profile, and resolved fail-closed.** The daemon holds a registry of the harnesses it can run (Claude Code, pi and the Codex CLI today) and selects one per turn from the *acting* profile's `harness`, so a pi reviewer and a Claude implementer coexist in one daemon process. A profile naming a harness the daemon cannot run is refused — at `radiald init`, before the agent record is published, and again at `radiald run` startup — never silently downgraded to another one: the published record is what a space's assignee menu offers, and it has to be true. - **Routing is operator-side configuration.** The daemon config maps artifact types to model/provider choices — e.g. `plan: {provider: anthropic, model: opus}`, `review: {provider: openai, model: gpt-5}`, `implementation: {provider: anthropic, model: sonnet}`. An agent only accepts requests for types it has an assignment for. This keeps routing decisions (and their costs) with the party paying for tokens, and gets reviewer/implementer model diversity for free when an operator assigns different models to those types. - An agent profile's `models` list declares routing capability and preference, not proof of what ran. Per-record `model` provenance is the daemon's write-time observation and remains attached to that signed version. @@ -302,6 +308,8 @@ **B. Configurable workflow definitions** (states/transitions/guards as records — a workflow engine). Rejected: the flexibility buys authoring complexity, a validation problem, a UI that must render arbitrary graphs, and a much harder materialization story. The artifact-type registry is the deliberate middle ground — types carry prompts and output shapes, never transitions, so extending the system is adding a noun, not authoring a graph. **C. Pure conversation-driven agents** (no structure; agents respond when mentioned, like social-media collaborators). Rejected as the control layer: "when do agents run" degenerates into mention-parsing, and outputs have no type or provenance. The artifact model is *not* this — requests are typed, signed records with explicit briefs and subjects. Conversation is kept as the interaction layer: threads, mentions, and awaiting-input questions ride alongside the request/artifact stream. The `answer` turn (§9, §10) is the deliberate near miss: an agent *does* reply in a thread, but only because a human wrote a typed request pinning the message to answer, and only the daemon-never-authors-one invariant keeps that from collapsing into this. + +**D. Space-scoped capabilities as one record per (profile, space)**, under a composed record key. Rejected in favor of the `scopes` overlay (§11): it multiplies §4's sanctioned in-place edit across an unbounded set of records, needs an rkey scheme plus garbage collection when a space is dropped, and breaks the "one agent record per profile, keyed by profile name" line that `radiald init`'s `--update` guard, the drift advisories and every doc rely on. The sibling rejection is **an admin-written, space-side overlay** ("this space uses these types from that agent"): wrong author. A capability is a self-description — what the *agent* offers — and admin curation of somebody else's capabilities is a different feature, not this one. **Generic op-log/CRDT materialization** (the readme's original framing). Rejected in favor of primary-record + typed-referencing-records + deterministic fold (§4, §6): same convergence guarantees, dramatically less machinery, and it's the idiomatic atproto shape. diff --git a/docs/operators.md b/docs/operators.md --- a/docs/operators.md +++ b/docs/operators.md @@ -142,6 +142,23 @@ registry data, so neither is counted by that coverage check — but a profile still has to list the name to be offered the work. Drop `answer` and the UI's "Ask an agent to reply" picker is simply empty in every space you serve. +- **Capabilities can differ per space.** A profile's `spaces` block overrides + `artifactTypes` for one space — plan-only in one, implementation in the next, + nothing at all in a third (an empty list is a legal statement). It is an + override rather than an addition, and a space you do not list is unaffected. + The daemon gates dispatch, claims and auto-review on the effective list, and + `radiald init --update` publishes it so the assignee menu narrows too. Full + reference: `docs/radial-json.md`, "One profile, different capabilities per + space". +- **In a private space, `radiald init --update` is the wrong instruction, and the + daemon says so.** That fold reads agent records only off the private bus, so + the record init writes to a PDS is one it will never read. `radiald run` + publishes each profile's capabilities into the replica itself — at startup and + again whenever the config and the replica disagree — so the sequence for a + private space is *edit `radial.json`, restart the daemon*. If a profile's + capabilities are missing there for more than a moment, the daemon warns and + keeps retrying; the usual cause is a replica that has not yet read this + daemon's own `device` record back out of its public repo. --- diff --git a/docs/radial-json.md b/docs/radial-json.md --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -93,6 +93,7 @@ | Field | Meaning | | --- | --- | | `artifactTypes` | The type names this profile accepts: registry names, plus the built-ins `review` and `answer`. Required, and non-empty. See below — this is the field that has to agree with something outside this file. | +| `spaces` | Per-space overrides of `artifactTypes`, keyed by space URI. Optional. See "One profile, different capabilities per space". | | `identifier` | A second agent account for this profile. Its app password is read from `$RADIAL__PASSWORD` (e.g. `RADIAL_OTHER_EXAMPLE_PASSWORD`), not from `$RADIAL_PASSWORD`. | | `pds`, `harness`, `models` | Per-profile overrides of the top-level defaults. Overriding `harness` is what lets one identity run, say, a `pi` reviewer beside a `claude` implementer — see "Choosing a harness and a provider". | | `name` | The handle name published in the agent record. Defaults to the profile name. | @@ -543,6 +544,55 @@ if a daemon keeps warning about drift after a republish, it is running a build from before that rule, and `radiald index reset` (with the daemon stopped) is the workaround. + +### One profile, different capabilities per space + +A daemon commonly serves several spaces with one set of profiles, and the same +profile is not always wanted for the same work in each. `spaces` says so: + +```json +"agents": { + "planner": { + "artifactTypes": ["plan", "adr"], + "spaces": { + "at://did:plc:…/com.disnetdev.radial.space/abc": { "artifactTypes": ["plan"] }, + "at://did:plc:…/com.disnetdev.radial.space/xyz": { "artifactTypes": [] } + } + } +} +``` + +An entry is an **override, not an addition**: in that space, its `artifactTypes` +is the whole answer. An empty list is legal and means "this profile does nothing +here" — the one thing `artifactTypes` cannot say, since it must be non-empty. A +space with no entry gets `artifactTypes` unchanged, which is every config written +before this existed. + +It gates everything the daemon does per space — dispatch, claiming, auto-review, +merge annotations — and it is published, so a human's assignee menu narrows with +it. `radiald init --update` writes the overrides into the agent record +as `scopes`, and treats a changed one as drift like any other field: + +``` +✗ planner Agent profile planner is already published with different capabilities: + space at://…/space/abc: published plan, adr, config plan (drops adr) + Re-run with --update to republish it from this config. +``` + +Two things are worth knowing before you use it: + +- **A scope naming a space this instance does not serve is a warning, not an + error.** A second instance sharing this config may serve it. But a URI with a + typo in it is a narrowing that will never take effect, so the daemon says so at + startup. +- **A private space's scope is never published.** A private space's fold reads + agent records only from the private bus, so `radiald init` cannot give one an + assignee list at all: `--update` writes to a PDS, which that fold never reads. + The running daemon publishes them into the replica itself, resolved to that + space's effective list — so for a private space the sequence is *edit + `radial.json`, restart `radiald run`*, with no `init --update` step and nothing + to republish by hand. (This is also why the coverage warning below reads + differently for a private space.) ### `review` and `answer` are not registry types diff --git a/docs/running-an-agent.md b/docs/running-an-agent.md --- a/docs/running-an-agent.md +++ b/docs/running-an-agent.md @@ -123,6 +123,11 @@ - **`review` and `answer` are built-ins**, not registry data: `radial type create` refuses both names. A profile still has to list them to be offered one, which is why the scaffold puts both on the reviewer. +- **One profile can accept different types in different spaces.** Add a + `"spaces"` block to it, keyed by space URI; that space's list overrides + `artifactTypes` rather than adding to it, and an empty list means the profile + does nothing there. See + [One profile, different capabilities per space](radial-json.md#one-profile-different-capabilities-per-space). - **The PDS is resolved from the identity itself.** Set `pds` explicitly only to override that, such as against a local PDS in testing. @@ -146,7 +151,11 @@ published agent record is what an assignee menu and auto-review read. Republish with `radiald init --update`; without `--update` a changed profile is refused rather than rewritten, so a second config or a stale -checkout can't quietly redefine what a running agent advertises. +checkout can't quietly redefine what a running agent advertises. A **private** +space is the exception in both directions: `init` cannot help there (its record +goes to a PDS, which a private space's fold never reads), and it does not need +to — `radiald run` publishes each profile's capabilities into the replica +itself, so editing `radial.json` and restarting the daemon is the whole of it. A profile may override `identifier` to use a second agent account, whose app password is read from `$RADIAL__PASSWORD` (e.g. diff --git a/packages/daemon/README.md b/packages/daemon/README.md --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -163,6 +163,19 @@ about where its project's code review happens. An actor with no device key on this machine is dropped from the registry rather than left holding a PDS client. +- **`reconcileCapabilities`** (`private-space.ts`) is the one thing the daemon must do here that it + never does in a public space: publish its own profiles' `agent` records into the replica. A private + fold reads `agent` only out of an envelope — the device gate, and correctly, since a capability + anybody with repo custody could mint is not one the space granted — so `radiald init`'s PDS write + is invisible here and `index.agents` would stay empty forever. It writes the effective types for + *this* space (`actorTypesFor`) resolved into `artifactTypes` with no `scopes` field, keyed by the + profile name like every other agent record, and only when the record differs: the comparison sorts + and ignores `createdAt`, because an unstable one would seal an envelope every tick that every peer + would then keep. `openPrivateSpaces` runs it before the first fold (so the coverage advisory never + fires on a space the daemon is about to cover itself) and `refreshCapabilities()` retries it on the + address-refresh interval, for the replica that had not yet read this daemon's own device record + back and so refused its own first envelope. + `packages/daemon/test/private-dispatch.test.mjs` runs the whole path over `MemoryPrivateBus`: two daemons catch up off the bus, one wins the claim by the existing deterministic tie-break, its turn's artifact is sealed into an envelope the other folds, and every replica's `indexDigest()` agrees — diff --git a/packages/lexicons/README.md b/packages/lexicons/README.md --- a/packages/lexicons/README.md +++ b/packages/lexicons/README.md @@ -64,6 +64,49 @@ chosen by whoever wrote the artifact is what keeps the signed field deliberate, and keeps two implementations from disagreeing about what a record is called. +## An agent's `scopes` overrides `artifactTypes` per space, and a writer owes both + +`com.disnetdev.radial.agent` carries an optional `scopes`: a list of +`{space, artifactTypes}` saying what this profile accepts **in one space** +(design §11). It is not in `required` and must not become required, for the +`title` reason above — every agent record already in a repo was signed without +it, and a materializer that dropped them would show a space with no agents at +all. + +The rule is **override, not intersection**, and the empty list is meaningful: + +- in space S, the effective types are the entry naming S if one exists, **else** + the top-level `artifactTypes`; +- an entry with `artifactTypes: []` says "this profile does nothing here" — the + one thing `artifactTypes` alone cannot say, since it must be non-empty; +- entries naming other spaces are inert, and a space with no entry gets the + top-level list, which is exactly how every record written before scoping + existed behaves. + +Readers must go through one resolver rather than re-spelling that rule: +`agentTypesFor(record, spaceUri)` in `@radial/core`, beside the record helpers. + +The obligations on a writer are three, and they exist because an old reader +degrades visibly rather than safely (a narrowed scope makes an old assignee menu +over-offer, and the request it assigns then sits open): + +- **`artifactTypes` stays truthful on its own.** It is what every reader that + does not know about `scopes` will show, so it is the profile's global answer + and not a placeholder. +- **`scopes` is sorted** — by `space`, and by type within each entry — and so is + `artifactTypes` when a writer republishes both. The record is compared to + decide whether to republish at all (`radiald init --update`, and the daemon's + per-tick reconcile in a private space); an unstable order there is a record + rewritten forever, kept in every revision by every peer. +- **the field is omitted, never written empty.** A profile that scopes nothing + must go on producing byte-for-byte the record it always produced. + +Private spaces take no entry here at all. Their fold reads `agent` only out of a +signed envelope, so a `scopes` entry naming one would be written where nothing +reads it; the daemon publishes into the replica instead, with that space's +effective list resolved into `artifactTypes` and no `scopes` field (design §11, +§18). + ## Model provenance is optional and daemon-attested Artifacts, reviews, and messages may carry an optional `model` string (at most 500 diff --git a/packages/ui/README.md b/packages/ui/README.md --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -43,7 +43,7 @@ | `src/lib/diagnostics.ts` | `index.ignored` and `index.edits`, grouped for display. | | `src/lib/build.ts` | Which copy of the app this tab is running — the constants `scripts/build-stamp.mjs` reads at build time and `vite.config.ts` injects, plus the origin the bundle was built for. Pure, and every input is an argument, so the one runtime value (where the tab is actually served from) is passed in. See *Build info* below. | | `src/lib/keys.ts`, `focus.ts` | The keyboard map, and focus restoration. | -| `src/lib/directory.ts` | Membership → the row an actor gets: kind, initials, disc color. Color is derived from the DID, never authored. | +| `src/lib/directory.ts` | Membership → the row an actor gets: kind, initials, disc color. Color is derived from the DID, never authored. An agent's `artifactTypes` here is the union of its profiles' **effective** lists for the space the directory was built over (`agentTypesFor`, design §11), so every picker filtering on it is already space-scoped. | | `src/lib/prose.ts` | The comp's small `## ` + backtick markup grammar. Parses to a structure the component interpolates — no `{@html}` anywhere near an agent-authored body. | | `src/lib/editor.ts` | CodeMirror, assembled and loaded on demand. The only module that imports it, and only through `import()` — `test/browser-bundle.test.mjs` fails if a static one appears anywhere under `src`. | | `src/lib/insertion.ts` | Where an uploaded image goes: carrying an insertion point across what the reader typed while the blob was on the wire, and refusing the one point that would nest two images. Pure, and held without a DOM. | diff --git a/packages/core/src/records.ts b/packages/core/src/records.ts --- a/packages/core/src/records.ts +++ b/packages/core/src/records.ts @@ -1,4 +1,5 @@ import type { + AgentRecord, ArtifactRecord, ArtifactRequestRecord, MessageRecord, @@ -33,5 +34,26 @@ */ export const joinRkey = (spaceDid: string, spaceRkey: string): string => `join-${spaceDid.slice(spaceDid.lastIndexOf(':') + 1).replace(/[^a-zA-Z0-9.~_-]/g, '-')}-${spaceRkey}` + +/** + * What a profile accepts IN ONE SPACE — the single reading of an agent record's capabilities, shared + * by the assignee picker, the daemon's coverage advisories and every test, so there is one rule and + * not three spellings of it (design §11). + * + * The rule is OVERRIDE, not intersection: a `scopes` entry naming this space replaces the top-level + * `artifactTypes` outright, and an entry with an empty list is a valid statement — "this profile does + * nothing here" — rather than a missing one. Entries naming other spaces are inert; a record with no + * entry for this space falls back to the top-level list, which is what every record written before + * scoping existed does, so an unscoped agent behaves exactly as it always has. + * + * The fold never rewrites a signed value, so `index.agents` keeps carrying raw records and consumers + * resolve through here instead. First entry wins if a record somehow names one space twice: the + * resolution has to be a pure function of the record, and "the first one" is the only tie-break that + * needs no ordering nobody guaranteed. + */ +export function agentTypesFor(record: AgentRecord, spaceUri: string): string[] { + const scope = record.scopes?.find((entry) => entry.space === spaceUri) + return [...(scope ? scope.artifactTypes : record.artifactTypes)] +} export * from './generated/records.js' diff --git a/packages/core/test/materializer.test.mjs b/packages/core/test/materializer.test.mjs --- a/packages/core/test/materializer.test.mjs +++ b/packages/core/test/materializer.test.mjs @@ -3,6 +3,7 @@ import { materialize } from '../dist/materializer.js' import { MemoryRecordStore } from '../dist/store.js' import { goldenScenario } from '../dist/test/scenario.js' +import { agentTypesFor } from '../dist/records.js' import { COLLECTIONS } from '../dist/generated/records.js' import { indexDigest } from '../dist/digest.js' import { compareCodePoints } from '../dist/order.js' @@ -608,6 +609,36 @@ entry.reason === 'record does not resolve into this space index', ), true, + ) + }) + + it('resolves a profile\'s capabilities per space, overriding rather than intersecting', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const scoped = index.agents.find((agent) => agent.uri === scenario.scopedAgentUri) + // The fold hands back the signed record untouched — scoping is resolved by the reader, not by + // rewriting what somebody signed. + assert.deepEqual(scoped.value.artifactTypes, ['review', 'adr']) + // Override, not intersection: the entry naming this space REPLACES the top-level list, so `adr` + // is off the table here even though the profile still advertises it globally. + assert.deepEqual(agentTypesFor(scoped.value, scenario.spaceUri), ['review']) + // The entry naming another space is inert here and is the whole answer there. + assert.deepEqual(agentTypesFor(scoped.value, scenario.foreignSpaceUri), ['plan', 'implementation']) + // A space with no entry falls back to the top-level list — which is every record written before + // scoping existed, and why this is additive. + assert.deepEqual( + agentTypesFor(scoped.value, 'at://did:plc:nobody/com.disnetdev.radial.space/third'), + ['review', 'adr'], + ) + const unscoped = index.agents.find((agent) => agent.value.handleName === 'builder.radial.bot') + assert.deepEqual(agentTypesFor(unscoped.value, scenario.spaceUri), ['implementation']) + // An empty list is a statement, not a missing one: "this profile does nothing here". + assert.deepEqual( + agentTypesFor( + { ...unscoped.value, scopes: [{ space: scenario.spaceUri, artifactTypes: [] }] }, + scenario.spaceUri, + ), + [], ) }) diff --git a/packages/core/test/private-fold.test.mjs b/packages/core/test/private-fold.test.mjs --- a/packages/core/test/private-fold.test.mjs +++ b/packages/core/test/private-fold.test.mjs @@ -68,6 +68,24 @@ assert.equal(index.members.some((member) => member.did === scenario.injectedAdminDid), false) }) + it('counts an agent profile only from an envelope, never from its author\'s PDS', () => { + const scenario = privateScenario() + const index = build(scenario.records, scenario.spaceUri) + // The enveloped profile is the whole of `index.agents` here: an assignee list a private space + // has at all is one the daemon sealed into the replica. + assert.deepEqual(index.agents.map((agent) => agent.uri), [scenario.envelopeAgentUri]) + assert.deepEqual(index.agents[0].value.artifactTypes, ['implementation', 'review']) + // A private replica is per-space, so effective types are resolved before sealing and the + // scoping vocabulary never travels in an envelope. + assert.equal(index.agents[0].value.scopes, undefined) + // The same profile written to the agent's own repo — what `radiald init` publishes — is refused + // for the ordinary reason. `agent` is not on the public path, so custody of a repo is not a + // capability grant. + assert.deepEqual(reasonsFor(index, scenario.injectedAgentUri), [ + 'record did not arrive in a signed envelope', + ]) + }) + it('uses earliest creation and URI order for a contested device key id', () => { const scenario = privateScenario() const original = scenario.records.find( diff --git a/packages/core/test/scenario.ts b/packages/core/test/scenario.ts --- a/packages/core/test/scenario.ts +++ b/packages/core/test/scenario.ts @@ -65,6 +65,15 @@ claimLapsedUri: string originalGoalCid: string humanAuthoredAgentUri: string + /** + * AGENT_B's profile, which scopes itself: `review` only in THIS space, something else entirely in + * a space this fold has never heard of. Its top-level `artifactTypes` still says `review, adr`, so + * the record is exactly what an old writer would have produced plus an overlay — which is what + * makes it the fixture for the override rule (`agentTypesFor`). + */ + scopedAgentUri: string + /** The space AGENT_B's other scope entry names. Nothing here resolves into it. */ + foreignSpaceUri: string mergedArtifactUri: string implGoalUri: string untrustedMergeUri: string @@ -176,13 +185,21 @@ createdAt: '2026-01-01T00:00:06Z', }, 31), ) - add( + // AGENT_B scopes itself. The top-level list is what an old reader (and a space with no entry here) + // sees; the entry naming THIS space overrides it, narrowing the profile to `review` and taking + // `adr` off the table locally, and the entry naming a space this fold does not hold is inert. + const foreignSpaceUri = 'at://did:plc:elsewhere/com.disnetdev.radial.space/other' + const scopedAgent = add( stored(AGENT_B, 'reviewer', { $type: COLLECTIONS.agent, handleName: 'reviewer.radial.bot', harness: 'claude-code', models: [{ name: 'claude-opus-5', costHint: 'high' }], artifactTypes: ['review', 'adr'], + scopes: [ + { space: space.uri, artifactTypes: ['review'] }, + { space: foreignSpaceUri, artifactTypes: ['plan', 'implementation'] }, + ], createdAt: '2026-01-01T00:00:07Z', }, 32), ) @@ -1023,6 +1040,8 @@ claimLapsedUri: lapsedClaim.uri, originalGoalCid: goal.cid, humanAuthoredAgentUri: humanAuthoredAgent.uri, + scopedAgentUri: scopedAgent.uri, + foreignSpaceUri, mergedArtifactUri: implArtifact.uri, implGoalUri: implGoal.uri, untrustedMergeUri: untrustedMerge.uri, @@ -1110,6 +1129,16 @@ injectedGrantUri: string /** A message in a member's public repo, no envelope. Same rule, ordinary record. */ injectedMessageUri: string + /** + * The agent's capabilities, sealed into an envelope — the only carrier that counts here, and what + * the daemon publishes into a private replica so the space has an assignee list at all (§11, §18). + */ + envelopeAgentUri: string + /** + * The same profile written to the agent's own PDS instead. `agent` is not on the public path, so + * whoever holds that repo could otherwise grant themselves any capability they liked. Ignored. + */ + injectedAgentUri: string /** The stranger the injected grant tries to make an admin. */ injectedAdminDid: string /** @@ -1244,6 +1273,22 @@ }, 21, ['human-browser-2'], 'cid-private-goal'), ) + // The agent's self-description, in an envelope. A private space's fold reads no PDS, so this is + // the ONLY way `index.agents` is ever non-empty here — and the daemon writing it is what stops the + // uncovered-types advisory firing at every private startup. Scoping never travels in an envelope: + // a replica is per-space, so the effective list for THIS space is resolved into `artifactTypes` + // before it is sealed. + const envelopeAgent = add( + sealed(PRIVATE_AGENT, 'implementer', { + $type: COLLECTIONS.agent, + handleName: 'implementer.radial.bot', + harness: 'claude-code', + models: [{ name: 'claude-opus-5', costHint: 'high' }], + artifactTypes: ['implementation', 'review'], + createdAt: '2026-03-01T00:00:23Z', + }, 23, ['agent-node-1']), + ) + // The founder's ORIGINAL key signed this, and rotation retired that key without revoking it. Its // history still counts and stays verifiable forever. Rotation is hygiene and fold-neutral. const rotatedHistory = add( @@ -1305,6 +1350,20 @@ createdAt: '2026-03-01T00:00:31Z', }, 33), ) + // An agent record on the public path. `radiald init` writes exactly this, which is why the thread's + // report was a bug and not a misconfiguration: it is written to the one place a private fold will + // never read. Refused for the ordinary reason, and the refusal is load-bearing — a capability + // anybody with repo custody could mint is a capability the space did not grant. + const injectedAgent = add( + stored(PRIVATE_AGENT, 'pds-implementer', { + $type: COLLECTIONS.agent, + handleName: 'implementer.radial.bot', + harness: 'claude-code', + models: [], + artifactTypes: ['implementation', 'plan', 'adr'], + createdAt: '2026-03-01T00:00:33Z', + }, 35), + ) const publicJoin = add( stored(PRIVATE_HUMAN, joinRkey(PRIVATE_ROOT, 'space'), { $type: COLLECTIONS.join, @@ -1338,6 +1397,8 @@ guestCommentsUri: guestComments.uri, injectedGrantUri: injectedGrant.uri, injectedMessageUri: injectedMessage.uri, + envelopeAgentUri: envelopeAgent.uri, + injectedAgentUri: injectedAgent.uri, injectedAdminDid: PRIVATE_STRANGER, publicJoinUri: publicJoin.uri, addressUri: address.uri, diff --git a/packages/daemon/src/actors.ts b/packages/daemon/src/actors.ts --- a/packages/daemon/src/actors.ts +++ b/packages/daemon/src/actors.ts @@ -8,7 +8,7 @@ RecordByCollection, StrongRef, } from '@radial/core' -import { parseModel, type RadialConfig } from './config.js' +import { parseModel, unservedScopes, type RadialConfig } from './config.js' /** * What the daemon does with an actor's identity: write Radial records, rewrite the few sanctioned @@ -50,7 +50,18 @@ export interface LoadedActor { did: string profile: string + /** The short name this profile publishes as (`agents..name`, else the profile name) — + * the same value `radiald init` put in its agent record, so a capability record the daemon + * publishes into a private replica reads identically to the one it publishes to a PDS. */ + handleName: string artifactTypes: string[] + /** + * Per-space overrides of the list above, keyed by space URI (`agents..spaces`). + * + * Never read directly — `actorTypesFor` is the one resolver, so config-side dispatch and the + * fold-side assignee menu apply the same override rule (design §11). + */ + spaces?: Record | undefined /** The harness name this profile runs turns on (its own, else the config default) — the same * value `radiald init` published in its agent record. Resolved to an actual harness per turn * (`turn.ts`), fail-closed, so a profile that says `pi` either runs pi or refuses to run. */ @@ -65,8 +76,49 @@ export interface ActorRegistry { all: LoadedActor[] byDid: Map - /** A loaded actor whose DID === did and that produces `type` (type ∈ its artifactTypes). Deterministic. */ - select(did: string, type: string): LoadedActor | undefined + /** + * A loaded actor whose DID === did and that produces `type` IN THIS SPACE + * (type ∈ `actorTypesFor(actor, spaceUri)`). Deterministic. + * + * The space is a parameter rather than a property of the registry because one daemon serves + * several spaces from one set of profiles, and a profile may accept `implementation` in one and + * nothing at all in the next. Every call site already runs per space and holds the URI. + */ + select(did: string, type: string, spaceUri: string): LoadedActor | undefined +} + +/** + * What a profile accepts in one space, config-side — the twin of `core`'s `agentTypesFor`, which + * answers the same question from the published record. + * + * The two must agree, and they are separate functions because they read different things: this one + * gates what the daemon will actually dispatch, claim and auto-review, and the other is what a + * human's assignee menu offers. Same rule in both — an entry naming this space overrides the global + * list outright, an empty list means "nothing here", and no entry means the global list. + */ +export function actorTypesFor(actor: LoadedActor, spaceUri: string): string[] { + const scope = actor.spaces?.[spaceUri] + return [...(scope ? scope.artifactTypes : actor.artifactTypes)] +} + +/** Whether this profile produces `type` in this space. The one predicate every eligibility filter uses. */ +export function actorAccepts(actor: LoadedActor, type: string, spaceUri: string): boolean { + return actorTypesFor(actor, spaceUri).includes(type) +} + +/** + * Whether this profile produces `type` ANYWHERE it is configured — the daemon-wide question. + * + * Two decisions are genuinely not per space and must not pretend to be: which identity signs a + * tangled push (one key per agent DID, not one per space) and whether a missing forge is worth + * warning about at all. Both used to read the global list alone, which would now overlook a profile + * that takes on `implementation` in one space through a scope and nowhere else. + */ +export function actorEverAccepts(actor: LoadedActor, type: string): boolean { + return ( + actor.artifactTypes.includes(type) || + Object.values(actor.spaces ?? {}).some((scope) => scope.artifactTypes.includes(type)) + ) } /** @@ -136,7 +188,10 @@ all.push({ did: session.did, profile, + // Same precedence `resolveAgentInits` uses when it publishes the record. + handleName: agent.name ?? profile, artifactTypes: agent.artifactTypes, + ...(agent.spaces ? { spaces: agent.spaces } : {}), harness, // Profile models override the config-level default, same as the harness above. models: (agent.models ?? config.models ?? []).map(parseModel), @@ -156,6 +211,15 @@ (options.configPath ? ` --config ${options.configPath}` : ''), ) } + // A per-space override for a space this instance does not run. Not fatal — see `unservedScopes` — + // but a narrowing nothing will ever apply is worth one line at startup. + for (const { profile, space } of unservedScopes(config)) { + options.warn?.( + `profile "${profile}" scopes its capabilities for ${space}${options.configPath ? ` in ${options.configPath}` : ''}, ` + + 'but this instance does not serve that space. The scope has no effect here; another instance ' + + 'sharing this config may still use it.', + ) + } const byDid = new Map() for (const actor of all) { const existing = byDid.get(actor.did) @@ -165,8 +229,8 @@ return { all, byDid, - select(did: string, type: string): LoadedActor | undefined { - return byDid.get(did)?.find((actor) => actor.artifactTypes.includes(type)) + select(did: string, type: string, spaceUri: string): LoadedActor | undefined { + return byDid.get(did)?.find((actor) => actorAccepts(actor, type, spaceUri)) }, } } diff --git a/packages/daemon/src/auto-review.ts b/packages/daemon/src/auto-review.ts --- a/packages/daemon/src/auto-review.ts +++ b/packages/daemon/src/auto-review.ts @@ -17,7 +17,7 @@ type RadialRecord, type StrongRef, } from '@radial/core' -import type { ActorRegistry, LoadedActor } from './actors.js' +import { actorAccepts, type ActorRegistry, type LoadedActor } from './actors.js' import { base32Encode } from './turn-socket.js' /** The one auto-review request type name the daemon emits (design §7). Matches a `review`-producing @@ -62,7 +62,8 @@ /** * The loaded actors eligible to author a review in this space: each holds an active AGENT-kind - * membership grant (mirroring dispatch.ts's agent-kind check) and produces `review` artifacts. + * membership grant (mirroring dispatch.ts's agent-kind check) and produces `review` artifacts HERE — + * a profile scoped out of this space is not one of them, however it is configured elsewhere. * Sorted by DID, so `[0]` is the deterministic writer and the list is a stable probe order. */ export function eligibleReviewActors(index: MaterializedIndex, actors: ActorRegistry): LoadedActor[] { @@ -70,7 +71,9 @@ index.members.filter((member) => member.active && member.kind === 'agent').map((member) => member.did), ) return actors.all - .filter((actor) => activeAgentDids.has(actor.did) && actor.artifactTypes.includes(REVIEW_TYPE)) + .filter( + (actor) => activeAgentDids.has(actor.did) && actorAccepts(actor, REVIEW_TYPE, index.space.uri), + ) .sort((a, b) => compareCodePoints(a.did, b.did)) } @@ -80,6 +83,10 @@ * deliberately broader than `eligibleReviewActors`: an actor that wrote the request and then lost its * membership grant (but is still loaded) must still be probed, or its record is missed and a * duplicate is written. Sorted by DID for a stable probe order. + * + * Space scoping is deliberately NOT applied here for the same reason membership is not: this is a + * probe order, not eligibility. A profile scoped out of a space today may well have written the + * request yesterday, and missing its record writes a duplicate. */ export function loadedReviewActors(actors: ActorRegistry): LoadedActor[] { return actors.all diff --git a/packages/daemon/src/claims.ts b/packages/daemon/src/claims.ts --- a/packages/daemon/src/claims.ts +++ b/packages/daemon/src/claims.ts @@ -17,7 +17,7 @@ type MessageRecord, type ProjectView, } from '@radial/core' -import type { ActorRegistry, LoadedActor } from './actors.js' +import { actorAccepts, type ActorRegistry, type LoadedActor } from './actors.js' import type { ClaimLedger, ClaimRow } from './claim-ledger.js' import { hasUnansweredQuestion, requestTypeContext, resolveRequestType } from './dispatch.js' import type { TurnLedger } from './ledger.js' @@ -169,7 +169,8 @@ const actor = actors.all .filter( - (candidate) => activeAgentDids.has(candidate.did) && candidate.artifactTypes.includes(type), + (candidate) => + activeAgentDids.has(candidate.did) && actorAccepts(candidate, type, index.space.uri), ) .sort((a, b) => compareCodePoints(a.did, b.did) || compareCodePoints(a.profile, b.profile))[0] if (!actor) continue @@ -457,7 +458,8 @@ view.openRequests.some((candidate) => candidate.uri === row.requestUri), ) const actor = - actors.select(row.actorDid, request?.value.type ?? '') ?? actors.byDid.get(row.actorDid)?.[0] + actors.select(row.actorDid, request?.value.type ?? '', spaceUri) ?? + actors.byDid.get(row.actorDid)?.[0] if (!request || !target || !actor) continue const covering = this.#deps.turns.get(row.requestUri)?.state === 'running' this.#track( diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts --- a/packages/daemon/src/cli.ts +++ b/packages/daemon/src/cli.ts @@ -16,6 +16,7 @@ import { FileSessionStore, defaultDataDirectory, nodeResolveTxt } from '@radial/atproto/node' import { MAX_CLAIM_LEASE_MS, + agentTypesFor, compareCodePoints, deviceKeyStatus, ensurePublishedDevice, @@ -25,7 +26,7 @@ type MaterializedIndex, } from '@radial/core' import { FileDeviceKeyStore } from '@radial/core/node' -import { loadActors, type ActorClient, type ActorRegistry } from './actors.js' +import { actorEverAccepts, actorTypesFor, loadActors, type ActorClient, type ActorRegistry } from './actors.js' import { AutoReviewTrigger } from './auto-review.js' import { ClaimLedger } from './claim-ledger.js' import { @@ -804,11 +805,23 @@ ): string[] { const advisories: string[] = [] const list = (values: readonly string[]): string => values.join(', ') + // In a private space `radiald init --update` is the wrong instruction — it writes to a PDS, and + // the private fold reads `agent` only out of an envelope. The daemon publishes those itself, so + // the operator's move is to edit the config and restart, and saying otherwise sent people to a + // command that could not have helped (design §18). + const republish = index.private + ? 'restart this daemon — it publishes capabilities into a private space itself, and ' + + `"radiald init --update" cannot: it writes to a PDS, which a private space's fold never reads` + : undefined // What the space's own records say is on offer, which is what a human's assignee menu reads — not // what this config believes, and not only this operator's agents: another member's daemon covering - // a type is a perfectly good answer, and warning about it would be this daemon guessing. - const accepted = new Set(index.agents.flatMap((agent) => agent.value.artifactTypes)) + // a type is a perfectly good answer, and warning about it would be this daemon guessing. Read + // through `agentTypesFor`, so a profile scoped down in THIS space does not count as covering a + // type it only accepts somewhere else. + const accepted = new Set( + index.agents.flatMap((agent) => agentTypesFor(agent.value, where.spaceUri)), + ) const registry = [...new Set(index.artifactTypes.map((record) => record.value.name))] const uncovered = registry.filter((name) => !accepted.has(name)) if (uncovered.length > 0) { @@ -818,26 +831,32 @@ advisories.push( `${where.spaceUri}: the registry has ${uncovered.length === 1 ? 'a type' : 'types'} no agent in the space accepts — ` + `${list(uncovered)}. A request for ${uncovered.length === 1 ? 'it' : 'one'} has no agent to assign and will sit open. ` + - `Add ${uncovered.length === 1 ? 'it' : 'them'} to a profile's "artifactTypes" in ${where.configPath}, then run: ` + - `radiald init ${target ?? ''} --update`, + `Add ${uncovered.length === 1 ? 'it' : 'them'} to a profile's "artifactTypes" in ${where.configPath}, then ` + + (republish ?? `run: radiald init ${target ?? ''} --update`), ) } // Config-vs-published drift, per profile this daemon actually loaded. Comparing against the record // published under the SAME profile name is the point: the daemon dispatches on the config, humans - // and auto-review assign from the record, and only the second one is visible in the space. + // and auto-review assign from the record, and only the second one is visible in the space. Both + // sides are the EFFECTIVE lists for this space, so a profile whose scope and whose record agree + // here is quiet even when their global lists differ. const published = new Map( index.agents .filter((agent) => actors.all.some((actor) => actor.did === agent.did)) - .map((agent) => [`${agent.did}/${profileOf(agent.uri)}`, agent.value.artifactTypes]), + .map((agent) => [ + `${agent.did}/${profileOf(agent.uri)}`, + agentTypesFor(agent.value, where.spaceUri), + ]), ) for (const actor of actors.all) { const record = published.get(`${actor.did}/${actor.profile}`) - if (!record || list([...record].sort()) === list([...actor.artifactTypes].sort())) continue + const configured = actorTypesFor(actor, where.spaceUri) + if (!record || list([...record].sort()) === list([...configured].sort())) continue advisories.push( `profile "${actor.profile}" publishes ${list(record)} but ${where.configPath} says ` + - `${list(actor.artifactTypes)} — the published record is what an assignee menu offers, so run: ` + - `radiald init ${actor.profile} --update`, + `${list(configured)} in this space — the published record is what an assignee menu offers, so ` + + (republish ?? `run: radiald init ${actor.profile} --update`), ) } return advisories @@ -1100,7 +1119,7 @@ // account, no operator token in the middle. The implementation-producing actor is the one that // authors its pull records, chosen the same deterministic way the merge poller picks a writer. const writer = (): (typeof actors.all)[number] | undefined => - [...actors.all].sort((a, b) => compareCodePoints(a.did, b.did)).find((actor) => actor.artifactTypes.includes('implementation')) ?? + [...actors.all].sort((a, b) => compareCodePoints(a.did, b.did)).find((actor) => actorEverAccepts(actor, 'implementation')) ?? [...actors.all].sort((a, b) => compareCodePoints(a.did, b.did))[0] const knownHosts = configured.knownHosts ?? [] // Pull state comes from Bobbin, tangled's public read-only appview, unless the operator turned @@ -1151,7 +1170,7 @@ const advisory = forgeAdvisory({ adapter: Boolean(forge), configured: run.forges.length > 0, - producesImplementation: actors.all.some((actor) => actor.artifactTypes.includes('implementation')), + producesImplementation: actors.all.some((actor) => actorEverAccepts(actor, 'implementation')), configPath, }) if (advisory) console.warn(advisory) @@ -1336,6 +1355,16 @@ void privateSpaces.refreshAddresses().catch((error: unknown) => { console.warn( `⚠️ could not republish this daemon's private endpoint address: ` + + `${error instanceof Error ? error.message : String(error)}`, + ) + }) + // On the same interval and for the same shape of reason: what a private space knows about + // this daemon is only what this daemon has sealed into it. An address that went stale and + // a capability record that never landed are both "the replica disagrees with the process", + // and both are a local read that touches nothing when the answer has not moved. + void privateSpaces.refreshCapabilities().catch((error: unknown) => { + console.warn( + `⚠️ could not republish agent capabilities into a private space: ` + `${error instanceof Error ? error.message : String(error)}`, ) }) @@ -1541,6 +1570,13 @@ console.log( `✓ ${init.profile.padEnd(width)} ${result.did} ${result.artifactTypes.join(', ')}${republished}`, ) + // And where that line does not apply. A scope is an override, so an operator who cannot see it + // would read the line above as the answer everywhere — which is the one thing it is not. + for (const scope of result.scopes) { + console.log( + ` ${' '.repeat(width)} in ${scope.space}: ${scope.artifactTypes.join(', ') || '(nothing)'}`, + ) + } // The one step Radial cannot take for the operator: a knot authorises a push against the // agent's DID, and only a repo owner can grant that. Say it exactly once per key. if (result.tangledKey && (result.tangledKey.created || result.tangledKey.published)) { diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -10,11 +10,27 @@ export interface AgentConfig { artifactTypes: string[] + /** + * What this profile accepts in ONE space, overriding `artifactTypes` there (design §11). + * + * Keyed by space URI. The rule is override, not intersection — an entry is the whole answer for + * that space — and an empty `artifactTypes` is a statement rather than an omission: "this profile + * does nothing here". A space with no entry gets `artifactTypes`, which is every config written + * before scoping existed. + * + * This is the DISPATCH side of the same fact the agent record publishes; `radiald init` writes the + * record's `scopes` from it, and `actorTypesFor` is the one resolver both halves of the daemon read. + */ + spaces?: Record | undefined identifier?: string | undefined pds?: string | undefined name?: string | undefined harness?: string | undefined models?: Array | undefined +} + +export interface AgentSpaceConfig { + artifactTypes: string[] } export interface RadialConfig { @@ -230,6 +246,32 @@ throw new TypeError(`${where} must be an array of strings`) } return value as string[] +} + +/** + * `agents..spaces` — a space URI to the types this profile accepts there. + * + * An empty list is accepted deliberately (it is the "nothing here" scope, and the one thing an + * operator cannot say with `artifactTypes` alone, which must be non-empty). A key that is not an + * `at://` URI is a mistake worth failing on: a space is named by its URI everywhere else in this + * file, and a typo'd key would otherwise sit there silently matching nothing. + */ +function agentSpaces(value: unknown, where: string): Record | undefined { + if (value === undefined) return undefined + if (!object(value)) throw new TypeError(`${where} must be an object keyed by space URI`) + const spaces: Record = {} + for (const [space, raw] of Object.entries(value)) { + if (!space.startsWith('at://')) { + throw new TypeError(`${where} keys must be space URIs (at://…), got "${space}"`) + } + if (!object(raw)) throw new TypeError(`${where}["${space}"] must be an object`) + const artifactTypes = strings(raw.artifactTypes, `${where}["${space}"].artifactTypes`) + if (artifactTypes === undefined) { + throw new TypeError(`${where}["${space}"] must declare "artifactTypes"`) + } + spaces[space] = { artifactTypes } + } + return spaces } function models(value: unknown, where: string): Array | undefined { @@ -539,6 +581,7 @@ } agents[profile] = { artifactTypes, + spaces: agentSpaces(raw.spaces, `agents.${profile}.spaces`), identifier: text(raw.identifier, `agents.${profile}.identifier`), pds: text(raw.pds, `agents.${profile}.pds`), name: text(raw.name, `agents.${profile}.name`), @@ -634,6 +677,10 @@ ): AgentInitInput[] { const names = profiles.length > 0 ? profiles : Object.keys(config.agents) if (names.length === 0) throw new Error('Radial config defines no agents') + // A private space's capabilities do not belong in a PDS record: the private fold reads `agent` + // only out of an envelope, so an entry here would be written, never read, and never able to look + // anything but stale. The daemon publishes those into the replica instead (`private-run.ts`). + const privateSpaces = new Set((config.run?.privateSpaces ?? []).map((entry) => entry.space)) return names.map((profile) => { const agent = config.agents[profile] if (!agent) { @@ -658,8 +705,12 @@ ) } const service = agent.pds ?? config.pds + const scopes = Object.entries(agent.spaces ?? {}) + .filter(([space]) => !privateSpaces.has(space)) + .map(([space, scope]) => ({ space, artifactTypes: scope.artifactTypes })) return { profile, + ...(scopes.length > 0 ? { scopes } : {}), // Omitted: initializeAgent resolves the PDS from the identity's DID document. ...(service ? { service } : {}), identifier, @@ -670,6 +721,29 @@ artifactTypes: agent.artifactTypes, } }) +} + +/** + * Scope entries naming a space this instance does not serve, as (profile, space) pairs. + * + * A warning and never an error, because the entry is not necessarily wrong: an operator may serve + * that space from a SECOND instance sharing one config, and one profile's capabilities there are + * still this file's business. What it is worth saying out loud is the other case — a URI that was + * pasted with a typo, or a space dropped from `run` and left behind here — because a scope keyed to + * a space nothing resolves is a narrowing that will never take effect and never explain itself. + */ +export function unservedScopes(config: RadialConfig): Array<{ profile: string; space: string }> { + const served = new Set([ + ...(config.run?.spaces ?? []), + ...(config.run?.privateSpaces ?? []).map((entry) => entry.space), + ]) + const found: Array<{ profile: string; space: string }> = [] + for (const [profile, agent] of Object.entries(config.agents)) { + for (const space of Object.keys(agent.spaces ?? {})) { + if (!served.has(space)) found.push({ profile, space }) + } + } + return found } /** Environment variable holding an overriding identity's app password. */ diff --git a/packages/daemon/src/dispatch.ts b/packages/daemon/src/dispatch.ts --- a/packages/daemon/src/dispatch.ts +++ b/packages/daemon/src/dispatch.ts @@ -381,9 +381,15 @@ onReject?.(request.uri, `no loaded actor for assignee ${assignee} (profile not initialized in this config?)`) return undefined } - actor = actors.select(assignee, type) + actor = actors.select(assignee, type, index.space.uri) if (!actor) { - onReject?.(request.uri, `loaded actor for ${assignee} does not produce '${type}' artifacts`) + // "in this space" is the whole of the difference a scope makes: the profile may well + // produce this type elsewhere, and an operator reading the rejection needs to know which + // question was asked. + onReject?.( + request.uri, + `loaded actor for ${assignee} does not produce '${type}' artifacts in this space`, + ) return undefined } } else { @@ -406,9 +412,12 @@ onReject?.(request.uri, 'claim written but not yet confirmed through ingestion') return undefined } - actor = actors.select(winner.did, type) + actor = actors.select(winner.did, type, index.space.uri) if (!actor) { - onReject?.(request.uri, `loaded actor for claim holder ${winner.did} does not produce '${type}' artifacts`) + onReject?.( + request.uri, + `loaded actor for claim holder ${winner.did} does not produce '${type}' artifacts in this space`, + ) return undefined } } diff --git a/packages/daemon/src/init.ts b/packages/daemon/src/init.ts --- a/packages/daemon/src/init.ts +++ b/packages/daemon/src/init.ts @@ -9,7 +9,14 @@ type TxtResolver, } from '@radial/atproto' import { FileSessionStore, nodeResolveTxt, type StoredActor } from '@radial/atproto/node' -import { COLLECTIONS, type AgentModel, type AgentRecord, type StrongRef } from '@radial/core' +import { + COLLECTIONS, + compareCodePoints, + type AgentModel, + type AgentRecord, + type AgentScope, + type StrongRef, +} from '@radial/core' import type { TangledKeyStore } from './forge-auth.js' import { TANGLED_PUBLIC_KEY } from './forge-tangled.js' @@ -23,6 +30,15 @@ harness: string models: AgentModel[] artifactTypes: string[] + /** + * Per-space overrides to publish alongside the global list (design §11). + * + * PUBLIC spaces only — `resolveAgentInits` drops the private ones, and not because a private + * space's URI is a secret (a member's `join` bookmark is public and carries it). It is that the + * public record is a source the private fold ignores by construction: an entry here would be + * permanently unread and permanently unable to look anything but stale. + */ + scopes?: AgentScope[] now?: string } @@ -33,6 +49,8 @@ profile: StrongRef /** What the published record now says this profile accepts — what an assignee menu will offer. */ artifactTypes: string[] + /** The per-space overrides that record carries, if any: where the line above does not apply. */ + scopes: AgentScope[] /** What happened to the agent record: nothing, a first publish, or a republish under `--update`. */ action: 'created' | 'unchanged' | 'updated' /** Present when a tangled forge is configured: the agent's push key and whether this run created @@ -112,16 +130,87 @@ if (models(published) !== models(configured)) { lines.push(`models: published ${models(published)}, config ${models(configured)}`) } + // Per-space overrides, named one space at a time. The same reasoning as the global list: "the + // scopes changed" is true of a space that gained a type and of a space that was dropped from the + // config entirely, and the operator's next move differs. + const scopesOf = (record: AgentRecord): Map => + new Map((record.scopes ?? []).map((scope) => [scope.space, scope.artifactTypes])) + const publishedScopes = scopesOf(published) + const configuredScopes = scopesOf(configured) + const spaces = [...new Set([...publishedScopes.keys(), ...configuredScopes.keys()])] + for (const space of spaces.sort(compareCodePoints)) { + const before = publishedScopes.get(space) + const after = configuredScopes.get(space) + if (before && after && list([...before].sort()) === list([...after].sort())) continue + if (!before) { + lines.push(`space ${space}: config scopes it to ${list(after as string[])}, published record does not scope it`) + continue + } + if (!after) { + lines.push(`space ${space}: published as ${list(before)}, config no longer scopes it`) + continue + } + const had = new Set(before) + const has = new Set(after) + const change = [ + ...(after.some((type) => !had.has(type)) ? [`adds ${list(after.filter((type) => !had.has(type)))}`] : []), + ...(before.some((type) => !has.has(type)) ? [`drops ${list(before.filter((type) => !has.has(type)))}`] : []), + ] + lines.push( + `space ${space}: published ${list(before)}, config ${list(after)}` + + (change.length > 0 ? ` (${change.join(', ')})` : ''), + ) + } return lines } -export function buildAgentRecord(input: AgentInitInput): AgentRecord { +/** + * Sorted by space URI, and each entry's types sorted within it. + * + * Not cosmetic: `agentRecordComparable` decides whether `--update` republishes at all, and the + * private reconcile makes the same comparison every tick. An unstable order there is a record + * rewritten forever, with every peer keeping every revision of it. + */ +const sortScopes = (scopes: readonly AgentScope[]): AgentScope[] => + [...scopes] + .map((scope) => ({ space: scope.space, artifactTypes: [...scope.artifactTypes].sort(compareCodePoints) })) + .sort((a, b) => compareCodePoints(a.space, b.space)) + +/** + * The fields that decide whether a published agent record still says what this config says, as one + * comparable string. `createdAt` is deliberately out: it is stamped afresh on every write, so + * comparing it would republish forever — which on the private path means an envelope per tick, kept + * by every peer. + */ +export function agentRecordComparable(value: AgentRecord): string { + return JSON.stringify({ + handleName: value.handleName, + harness: value.harness, + models: value.models, + artifactTypes: value.artifactTypes, + // Sorted on both sides, so a record published by an older build (or with its spaces written in + // another order) compares equal when nothing actually changed. + scopes: sortScopes(value.scopes ?? []), + }) +} + +/** What `buildAgentRecord` actually reads — the private reconcile has no session to authenticate. */ +export type AgentRecordInput = Pick< + AgentInitInput, + 'handleName' | 'harness' | 'models' | 'artifactTypes' +> & { scopes?: AgentScope[] | undefined; now?: string | undefined } + +export function buildAgentRecord(input: AgentRecordInput): AgentRecord { return { $type: COLLECTIONS.agent, handleName: input.handleName, harness: input.harness, models: input.models, artifactTypes: input.artifactTypes, + // Omitted rather than emitted empty: a profile that scopes nothing must publish the record an + // unscoped profile always published, or every existing agent looks changed the first time an + // operator runs a build that knows about scoping. + ...(input.scopes && input.scopes.length > 0 ? { scopes: sortScopes(input.scopes) } : {}), createdAt: input.now ?? new Date().toISOString(), } } @@ -196,14 +285,9 @@ let reference: StrongRef let action: AgentInitResult['action'] if (existing) { - const comparable = (value: AgentRecord): string => - JSON.stringify({ - handleName: value.handleName, - harness: value.harness, - models: value.models, - artifactTypes: value.artifactTypes, - }) - if (comparable(existing.value as AgentRecord) !== comparable(record)) { + // Scopes are in the comparison (`agentRecordComparable`): without them `--update` would + // republish the global list and silently leave the per-space overrides behind. + if (agentRecordComparable(existing.value as AgentRecord) !== agentRecordComparable(record)) { const drift = capabilityDrift(existing.value as AgentRecord, record) if (!options.update) { // The published record is what an assignee menu reads, so a config that has gained a type @@ -264,6 +348,7 @@ service: stored.service, profile: reference, artifactTypes: record.artifactTypes, + scopes: record.scopes ?? [], action, ...(tangledKey ? { tangledKey } : {}), } diff --git a/packages/daemon/src/merge-poll.ts b/packages/daemon/src/merge-poll.ts --- a/packages/daemon/src/merge-poll.ts +++ b/packages/daemon/src/merge-poll.ts @@ -9,7 +9,7 @@ type MaterializedIndex, type MergeRecord, } from '@radial/core' -import type { ActorRegistry, LoadedActor } from './actors.js' +import { actorAccepts, type ActorRegistry, type LoadedActor } from './actors.js' import { base32Encode } from './turn-socket.js' /** @@ -63,10 +63,15 @@ * a merge is never written under a DID whose records the space would discard. Returns undefined when * no eligible actor exists. */ -export function selectWritingActor(eligible: LoadedActor[]): LoadedActor | undefined { +export function selectWritingActor( + eligible: LoadedActor[], + spaceUri: string, +): LoadedActor | undefined { const byDid = (a: LoadedActor, b: LoadedActor): number => compareCodePoints(a.did, b.did) + // "The implementer" is a per-space fact like every other capability: a profile scoped to plan-only + // here is not this space's implementer, whatever it does in the space next door. const implementers = eligible - .filter((actor) => actor.artifactTypes.includes('implementation')) + .filter((actor) => actorAccepts(actor, 'implementation', spaceUri)) .sort(byDid) if (implementers[0]) return implementers[0] return [...eligible].sort(byDid)[0] @@ -96,7 +101,7 @@ index.members.filter((member) => member.active).map((member) => member.did), ) const eligible = actors.all.filter((actor) => activeMemberDids.has(actor.did)) - const actor = selectWritingActor(eligible) + const actor = selectWritingActor(eligible, index.space.uri) if (!actor) { onSkip?.('no loaded actor holds an active membership grant in this space to author a merge') return [] diff --git a/packages/daemon/src/private-run.ts b/packages/daemon/src/private-run.ts --- a/packages/daemon/src/private-run.ts +++ b/packages/daemon/src/private-run.ts @@ -56,7 +56,12 @@ } from '@radial/core/node' import type { ActorRegistry } from './actors.js' import type { PrivateSpaceConfig } from './config.js' -import { PrivateSpaceRuntime, privateActorRegistry, privateWritersFor } from './private-space.js' +import { + PrivateSpaceRuntime, + privateActorRegistry, + privateWritersFor, + reconcileCapabilities, +} from './private-space.js' import { EndpointKeyStore, PrivateEndpoint, @@ -113,6 +118,18 @@ * happened to move, because the comparison would go on saying nothing had changed. */ refreshAddresses(): Promise + /** + * Publish each profile's capabilities into every private replica again, if the config and the + * replica have come apart. + * + * Startup publishes once (`openConfiguredSpace`, before the first fold, so the coverage advisory + * never fires on a space the daemon is about to cover itself). This is the retry, and it is a + * retry rather than an interval's worth of work: `reconcileCapabilities` writes nothing at all + * when nothing changed, so the steady state costs one local read per profile per space. What it + * is FOR is the case startup cannot handle — a replica that had not yet read back this daemon's + * own device record, and so refused its own first envelope. + */ + refreshCapabilities(): Promise close(): Promise } @@ -275,6 +292,28 @@ ) return publish() }, + async refreshCapabilities() { + for (const space of spaces) { + if (!space.actors) continue + for (const result of await reconcileCapabilities({ + spaceUri: space.spaceUri, + actors: space.actors, + ...(options.now ? { now: options.now } : {}), + log, + })) { + // A failure here is louder than the same failure at startup, and deliberately: startup's + // is the ordinary state of a replica that has not read back its own device record yet, + // and this one has already had a whole interval to stop being that. + if (result.action === 'failed') { + warn( + `could not publish ${result.profile}'s capabilities into ${space.spaceUri}: ` + + `${result.reason ?? 'unknown error'}. Retrying on the next pass; until it lands, ` + + 'no request in that space can be assigned to it.', + ) + } + } + } + }, async close() { await endpoint.close() for (const space of spaces) space.runtime.close() @@ -395,6 +434,26 @@ ` serving private ${spaceUri} from ${directory} ` + `(${actors.all.length} profile(s), founder ${founder}${spacePin ? ', ticket pinned' : ''})`, ) + // Before the run loop's first fold, which is where `onFirstIndex` gives the coverage advisory: + // publishing after it would mean an operator saw "the registry has types no agent accepts" at + // every single startup of a space the daemon was about to cover itself. The directory has + // already been polled above, so this daemon's own device is verifiable and its envelopes admit. + for (const result of await reconcileCapabilities({ + spaceUri, + actors, + ...(options.now ? { now: options.now } : {}), + log: options.log, + })) { + if (result.action === 'failed') { + // Not a warning and not fatal: a replica with no peer yet has not read back the device + // record it verifies its own envelopes against, and that is the ordinary first-run state + // rather than a misconfiguration. `refreshCapabilities` retries, and warns if it persists. + options.log( + ` ${result.profile}'s capabilities are not in ${spaceUri} yet (${result.reason ?? 'unknown error'}); ` + + 'retrying while it catches up', + ) + } + } return { spaceUri, runtime, actors } } catch (error) { runtime.close() diff --git a/packages/daemon/src/private-space.ts b/packages/daemon/src/private-space.ts --- a/packages/daemon/src/private-space.ts +++ b/packages/daemon/src/private-space.ts @@ -21,8 +21,11 @@ import type { RepoReadTransport } from '@radial/atproto' import { + COLLECTIONS, EnvelopeWriter, + compareCodePoints, materialize, + type AgentRecord, type Collection, type EnvelopeSigner, type MaterializedIndex, @@ -39,7 +42,14 @@ } from '@radial/core/node' import { PrivateSpaceIngestor, directoryPoller, privateWriter } from '@radial/ingest' import { SqliteSyncStateStore } from '@radial/ingest/node' -import type { ActorClient, ActorRegistry, LoadedActor } from './actors.js' +import { + actorAccepts, + actorTypesFor, + type ActorClient, + type ActorRegistry, + type LoadedActor, +} from './actors.js' +import { agentRecordComparable, buildAgentRecord } from './init.js' import type { SpaceRuntime } from './runtime.js' /** The stores a bus is built over, handed to a factory that cannot exist before they do. */ @@ -204,7 +214,8 @@ return { all, byDid, - select: (did, type) => byDid.get(did)?.find((actor) => actor.artifactTypes.includes(type)), + select: (did, type, spaceUri) => + byDid.get(did)?.find((actor) => actorAccepts(actor, type, spaceUri)), } } @@ -235,6 +246,95 @@ putForeign: (collection, uri, value, options) => pds.putForeign(collection, uri, value, options), uploadBlob: (bytes, contentType) => pds.uploadBlob(bytes, contentType), } +} + +/** What one pass of `reconcileCapabilities` did to one profile's record. */ +export interface CapabilityPublication { + did: string + profile: string + action: 'created' | 'updated' | 'unchanged' | 'failed' + /** Why, when `action` is `failed`. The pass carries on and the next tick tries again. */ + reason?: string +} + +/** + * The agent records a private space's own replica holds — published by the DAEMON, into envelopes. + * + * This is the private half of what `radiald init` does on a PDS, and it exists because init's half + * cannot work here: a private fold reads `agent` only out of an envelope (`PUBLIC_PATH_COLLECTIONS` + * in `core/src/devices.ts`, and correctly — a capability anybody with repo custody could mint is not + * a capability the space granted), so a record on the public path is written where nothing will ever + * read it. Without this, `index.agents` is empty in every private space: the assignee picker offers + * nobody, and the coverage advisory fires at every startup naming a fix that could not have helped. + * + * Three things about the record it writes: + * + * - **the effective types for THIS space are resolved into `artifactTypes`, and no `scopes` field is + * written.** A replica is per-space, so the scoping vocabulary has nothing to say inside one; + * - **the rkey is the profile name**, exactly as on the public path, so one profile has one record + * however many buses it writes on and `--update`'s republish rule transfers unchanged; + * - **it is written only when it differs.** The comparison is `agentRecordComparable`, which sorts + * and omits `createdAt` — an unstable comparison here would seal a fresh envelope every tick, + * forever, and every peer would keep every revision of it. + * + * The daemon is the writer rather than `radiald init` because init holds no replica, and a CLI must + * not open one the daemon is syncing. Failures are per profile and never fatal: a replica that has + * not yet read back its own device record refuses its own envelope, which is the ordinary state of + * the first seconds of a fresh space, so the pass reports it and the next tick tries again. + */ +export async function reconcileCapabilities(options: { + spaceUri: string + /** The PRIVATE registry — `privateActorRegistry`'s, whose clients seal rather than post. */ + actors: ActorRegistry + now?: () => string + log?: (message: string) => void +}): Promise { + const published: CapabilityPublication[] = [] + for (const actor of [...options.actors.all].sort( + (a, b) => compareCodePoints(a.did, b.did) || compareCodePoints(a.profile, b.profile), + )) { + const desired = buildAgentRecord({ + handleName: actor.handleName, + harness: actor.harness, + models: actor.models, + // Sorted for the same reason the scopes are: this string is compared every tick. + artifactTypes: [...actorTypesFor(actor, options.spaceUri)].sort(compareCodePoints), + ...(options.now ? { now: options.now() } : {}), + }) + try { + const existing = await actor.client.getOwnRecord(COLLECTIONS.agent, actor.profile) + if (!existing) { + await actor.client.create(COLLECTIONS.agent, desired, { rkey: actor.profile }) + options.log?.( + `private: published ${actor.profile}'s capabilities into ${options.spaceUri} ` + + `(${desired.artifactTypes.join(', ') || 'nothing'})`, + ) + published.push({ did: actor.did, profile: actor.profile, action: 'created' }) + continue + } + if (agentRecordComparable(existing.value as AgentRecord) === agentRecordComparable(desired)) { + published.push({ did: actor.did, profile: actor.profile, action: 'unchanged' }) + continue + } + // Same rkey, so the profile keeps its identity and every request already assigned to it still + // resolves — an agent's republish is one of the sanctioned in-place rewrites on this path + // (`EnvelopeWriter.put`). `swapRecord` pins the version this was derived from. + await actor.client.put(COLLECTIONS.agent, existing.uri, desired, { swapRecord: existing.cid }) + options.log?.( + `private: republished ${actor.profile}'s capabilities in ${options.spaceUri} ` + + `(${desired.artifactTypes.join(', ') || 'nothing'})`, + ) + published.push({ did: actor.did, profile: actor.profile, action: 'updated' }) + } catch (error) { + published.push({ + did: actor.did, + profile: actor.profile, + action: 'failed', + reason: error instanceof Error ? error.message : String(error), + }) + } + } + return published } /** Build the envelope writers a private space's actors sign with, from this machine's device keys. */ diff --git a/packages/daemon/test/auto-review.test.mjs b/packages/daemon/test/auto-review.test.mjs --- a/packages/daemon/test/auto-review.test.mjs +++ b/packages/daemon/test/auto-review.test.mjs @@ -7,7 +7,9 @@ import { XrpcError } from '../../atproto/dist/index.js' import { AutoReviewTrigger, + actorTypesFor, autoReviewRkey, + eligibleReviewActors, selectAutoReviewCandidates, selectReviewActor, } from '../dist/index.js' @@ -149,11 +151,13 @@ } } -function fakeActor(did, artifactTypes, creates) { +function fakeActor(did, artifactTypes, creates, spaces = undefined) { return { did, profile: did, + handleName: did, artifactTypes, + ...(spaces ? { spaces } : {}), session: { did }, client: { async create(collection, value, options) { @@ -250,7 +254,8 @@ return { all: actors, byDid, - select: (did, type) => byDid.get(did)?.find((a) => a.artifactTypes.includes(type)), + // Per space, like the real registry: a reviewer scoped out of a space is not one here. + select: (did, type, spaceUri) => byDid.get(did)?.find((a) => actorTypesFor(a, spaceUri).includes(type)), } } @@ -277,6 +282,24 @@ const b = fakeActor(AGENT_B, ['review'], []) assert.equal(selectReviewActor([b, a]).did, AGENT_A) assert.equal(selectReviewActor([]), undefined) + }) + + it('skips a reviewer scoped out of this space, and takes it in one it does not scope', () => { + const index = makeIndex({ members: [AGENT_A, AGENT_B] }) + const scoped = fakeActor(AGENT_A, ['review'], [], { + [index.space.uri]: { artifactTypes: ['plan'] }, + }) + const other = fakeActor(AGENT_B, ['review'], []) + // Lowest DID would be AGENT_A, but not here: it does not review in this space at all. + assert.deepEqual( + eligibleReviewActors(index, registry([scoped, other])).map((a) => a.did), + [AGENT_B], + ) + const elsewhere = { ...index, space: { ...index.space, uri: `at://${ROOT}/${COLLECTIONS.space}/two` } } + assert.deepEqual( + eligibleReviewActors(elsewhere, registry([scoped, other])).map((a) => a.did), + [AGENT_A, AGENT_B], + ) }) }) diff --git a/packages/daemon/test/claims.test.mjs b/packages/daemon/test/claims.test.mjs --- a/packages/daemon/test/claims.test.mjs +++ b/packages/daemon/test/claims.test.mjs @@ -7,6 +7,7 @@ ClaimManager, MAX_CLAIM_GENERATIONS, TurnLedger, + actorTypesFor, claimRkey, selectClaimable, } from '../dist/index.js' @@ -170,11 +171,13 @@ } } -function actorFor(did, artifactTypes = ['plan']) { +function actorFor(did, artifactTypes = ['plan'], spaces = undefined) { return { did, profile: 'planner', + handleName: 'planner', artifactTypes, + ...(spaces ? { spaces } : {}), harness: 'claude', models: [], session: { did, handle: `${did.split(':').at(-1)}.test`, service: 'https://pds.test', accessJwt: 'a', refreshJwt: 'r' }, @@ -192,7 +195,10 @@ return { all: actors, byDid, - select: (did, type) => byDid.get(did)?.find((actor) => actor.artifactTypes.includes(type)), + // The real registry's rule, not a simpler one: capabilities are per space (design §11), and a + // stand-in that ignored the space would let a test pass over a daemon that could not. + select: (did, type, spaceUri) => + byDid.get(did)?.find((actor) => actorTypesFor(actor, spaceUri).includes(type)), } } @@ -238,6 +244,29 @@ assert.deepEqual(candidates.map((candidate) => candidate.request.uri), [base.request.uri]) assert.equal(candidates[0].actor.did, AGENT) s.close() +}) + +it('does not claim work a profile is scoped out of here, and still claims it next door', () => { + // One profile, two spaces, one config: `plan` everywhere except this space, where it is scoped to + // implementations only. The open request here is a plan. + const here = buildScenario() + const scoped = actorFor(AGENT, ['plan'], { [SPACE_URI]: { artifactTypes: ['implementation'] } }) + const s = stack(here.records, { actors: [scoped] }) + assert.deepEqual(selectClaimable(s.index(), s.registry, s.claims, s.turns, { budget: 5 }), []) + s.close() + + // The same profile in a space it does not scope: the global list applies and it claims. + const next = buildScenario({ suffix: 'two' }) + const t = stack(next.records, { actors: [scoped] }) + const candidates = selectClaimable( + indexOf(next.records, `at://${ROOT}/${COLLECTIONS.space}/spacetwo`), + t.registry, + t.claims, + t.turns, + { budget: 5 }, + ) + assert.deepEqual(candidates.map((candidate) => candidate.request.uri), [next.request.uri]) + t.close() }) it('never claims an assigned request — the single-operator path takes no claim at all', () => { diff --git a/packages/daemon/test/config.test.mjs b/packages/daemon/test/config.test.mjs --- a/packages/daemon/test/config.test.mjs +++ b/packages/daemon/test/config.test.mjs @@ -16,6 +16,7 @@ passwordEnvName, resolveAgentInits, configuredForges, + unservedScopes, } from '../dist/index.js' import { resolveRunConfig } from '../dist/cli.js' @@ -509,4 +510,95 @@ resolveRunConfig({ spaces: [], privateSpaces: [{ space: PRIVATE_SPACE }] }).privateSpaces, [{ space: PRIVATE_SPACE }], ) +}) + +// ── space-scoped capabilities (design §11) ────────────────────────────────────────────────────── + +it('parses agents..spaces, including the empty "nothing here" scope', () => { + const parsed = parseConfig({ + ...config, + agents: { + planner: { + artifactTypes: ['plan', 'adr'], + spaces: { + [SPACE]: { artifactTypes: ['plan'] }, + [PRIVATE_SPACE]: { artifactTypes: [] }, + }, + }, + }, + }) + assert.deepEqual(parsed.agents.planner.spaces, { + [SPACE]: { artifactTypes: ['plan'] }, + // An empty list is a statement — "this profile does nothing here" — and the one thing + // `artifactTypes` alone cannot say, since that must be non-empty. + [PRIVATE_SPACE]: { artifactTypes: [] }, + }) + // A key that is not a space URI is a typo that would otherwise sit there matching nothing. + assert.throws( + () => + parseConfig({ + ...config, + agents: { planner: { artifactTypes: ['plan'], spaces: { 'space1': { artifactTypes: [] } } } }, + }), + /keys must be space URIs/, + ) + assert.throws( + () => + parseConfig({ + ...config, + agents: { planner: { artifactTypes: ['plan'], spaces: { [SPACE]: {} } } }, + }), + /must declare "artifactTypes"/, + ) + assert.throws( + () => + parseConfig({ + ...config, + agents: { planner: { artifactTypes: ['plan'], spaces: { [SPACE]: { artifactTypes: 'plan' } } } }, + }), + /must be an array of strings/, + ) +}) + +it('reports a scope for a space this instance does not serve, and stays quiet about served ones', () => { + const scoped = (run) => + parseConfig({ + ...config, + ...(run ? { run } : {}), + agents: { planner: { artifactTypes: ['plan'], spaces: { [SPACE]: { artifactTypes: ['plan'] } } } }, + }) + assert.deepEqual(unservedScopes(scoped({ spaces: [SPACE] })), []) + assert.deepEqual(unservedScopes(scoped({ spaces: [], privateSpaces: [{ space: SPACE }] })), []) + // A warning and never an error: a second instance sharing this config may well serve it. + assert.deepEqual(unservedScopes(scoped({ spaces: [PRIVATE_SPACE] })), [ + { profile: 'planner', space: SPACE }, + ]) + assert.deepEqual(unservedScopes(scoped(undefined)), [{ profile: 'planner', space: SPACE }]) +}) + +it('publishes scopes for public spaces only, leaving private ones to the daemon', () => { + const inits = resolveAgentInits( + parseConfig({ + ...config, + run: { spaces: [SPACE], privateSpaces: [{ space: PRIVATE_SPACE }] }, + agents: { + planner: { + artifactTypes: ['plan', 'adr'], + spaces: { + [SPACE]: { artifactTypes: ['plan'] }, + [PRIVATE_SPACE]: { artifactTypes: ['plan', 'implementation'] }, + }, + }, + impl: { artifactTypes: ['implementation'] }, + }, + }), + [], + { default: 'one-pw' }, + ) + // The private space's entry is dropped: the private fold reads `agent` only out of an envelope, + // so publishing it to a PDS would write a line nothing ever reads and that can only look stale. + assert.deepEqual(inits[0].scopes, [{ space: SPACE, artifactTypes: ['plan'] }]) + // A profile that scopes nothing publishes no `scopes` at all — an unscoped profile must go on + // producing exactly the record it always did. + assert.equal('scopes' in inits[1], false) }) diff --git a/packages/daemon/test/dispatch.test.mjs b/packages/daemon/test/dispatch.test.mjs --- a/packages/daemon/test/dispatch.test.mjs +++ b/packages/daemon/test/dispatch.test.mjs @@ -9,6 +9,7 @@ import { COLLECTIONS, MemoryRecordStore, buildTurnBundle, materialize } from '../../core/dist/index.js' import { LocalPds } from '../../atproto/test/local-pds.mjs' import { + actorTypesFor, selectHarness, FakeContainerRunner, ForgeRegistry, @@ -140,11 +141,13 @@ return s } -function actorFor(did, artifactTypes = ['plan'], models = [], harness = 'claude') { +function actorFor(did, artifactTypes = ['plan'], models = [], harness = 'claude', spaces = undefined) { return { did, profile: 'planner', + handleName: 'planner', artifactTypes, + ...(spaces ? { spaces } : {}), harness, models, session: { did, handle: `${did.split(':').at(-1)}.test`, service: 'https://pds.test', accessJwt: 'a', refreshJwt: 'r' }, @@ -162,8 +165,9 @@ return { all: actors, byDid, - select(did, type) { - return byDid.get(did)?.find((actor) => actor.artifactTypes.includes(type)) + // The real registry's rule: what a profile accepts is a per-space question (design §11). + select(did, type, spaceUri) { + return byDid.get(did)?.find((actor) => actorTypesFor(actor, spaceUri).includes(type)) }, } } @@ -350,6 +354,31 @@ [], ) assert.match(reasons[0], /unassigned/) + ledger.close() +}) + +it('leaves an assigned request alone when the assignee is scoped out of THIS space', () => { + const { records } = buildScenario() + const index = materialize(store(records), { spaceUri: SPACE_URI }) + const ledger = new TurnLedger() + // Globally a planner; here, scoped to reviews only. The request is assigned to it and stays put — + // the same "sits open" state a request whose type nobody accepts lands in, and the rejection says + // which question was asked. + const scoped = actorFor(AGENT, ['plan'], [], 'claude', { + [SPACE_URI]: { artifactTypes: ['review'] }, + }) + const reasons = [] + assert.deepEqual( + selectDispatchable(index, registryFor([scoped]), ledger, undefined, (_uri, reason) => + reasons.push(reason), + ), + [], + ) + assert.match(reasons[0], /does not produce 'plan' artifacts in this space/) + // The very same profile, in a space it does not scope: dispatched. + const elsewhere = materialize(store(records), { spaceUri: SPACE_URI }) + elsewhere.space = { ...elsewhere.space, uri: `at://${ROOT}/${COLLECTIONS.space}/other` } + assert.equal(selectDispatchable(elsewhere, registryFor([scoped]), ledger).length, 1) ledger.close() }) diff --git a/packages/daemon/test/init.test.mjs b/packages/daemon/test/init.test.mjs --- a/packages/daemon/test/init.test.mjs +++ b/packages/daemon/test/init.test.mjs @@ -361,6 +361,74 @@ ) }) +// ── space-scoped capabilities (design §11) ────────────────────────────────────────────────────── + +const SCOPED_SPACE = 'at://did:plc:human/com.disnetdev.radial.space/one' +const OTHER_SPACE = 'at://did:plc:human/com.disnetdev.radial.space/two' + +it('publishes scopes sorted, and omits the field entirely when nothing is scoped', () => { + const record = buildAgentRecord({ + ...initInput(['plan', 'review']), + scopes: [ + { space: OTHER_SPACE, artifactTypes: ['review', 'plan'] }, + { space: SCOPED_SPACE, artifactTypes: ['plan'] }, + ], + }) + // Sorted by space and within each entry, because this record is compared — by `--update` here and + // by the private reconcile every tick — and an unstable order is a republish that never stops. + assert.deepEqual(record.scopes, [ + { space: SCOPED_SPACE, artifactTypes: ['plan'] }, + { space: OTHER_SPACE, artifactTypes: ['plan', 'review'] }, + ]) + assert.equal(validateRecord(COLLECTIONS.agent, record).success, true) + // A profile that scopes nothing publishes exactly what it always published. + assert.equal('scopes' in buildAgentRecord(initInput(['plan'])), false) + assert.equal('scopes' in buildAgentRecord({ ...initInput(['plan']), scopes: [] }), false) +}) + +it('treats a per-space override as drift, and names the space that moved', async () => { + const scoped = { ...published, scopes: [{ space: SCOPED_SPACE, artifactTypes: ['plan'] }] } + // Same global list, different scope: a change `artifactTypes` alone could never show. + const lines = capabilityDrift(scoped, { + ...published, + scopes: [{ space: SCOPED_SPACE, artifactTypes: ['plan', 'adr'] }], + }) + assert.equal(lines.length, 1) + assert.match(lines[0], new RegExp(`space ${SCOPED_SPACE}: published plan, config plan, adr \\(adds adr\\)`)) + assert.match(capabilityDrift(published, scoped)[0], /config scopes it to plan, published record does not scope it/) + assert.match(capabilityDrift(scoped, published)[0], /published as plan, config no longer scopes it/) + assert.deepEqual(capabilityDrift(scoped, scoped), []) + // And it is genuine drift, so --update is what publishes it and nothing else does. + const directory = await mkdtemp(join(tmpdir(), 'radial-daemon-')) + const { writes, fetcher } = publishedProfile(published) + const scopes = [{ space: SCOPED_SPACE, artifactTypes: ['plan'] }] + await assert.rejects( + initializeAgent({ ...initInput(['plan']), scopes }, { + store: new FileSessionStore(directory), + fetcher, + }), + /config scopes it to plan, published record does not scope it/, + ) + assert.deepEqual(writes, []) + const result = await initializeAgent({ ...initInput(['plan']), scopes }, { + store: new FileSessionStore(directory), + fetcher, + update: true, + }) + assert.equal(result.action, 'updated') + assert.deepEqual(result.scopes, scopes) + assert.deepEqual(writes[0].body.record.scopes, scopes) + // Idempotent: the published record now says what the config says, scopes and all. + const again = publishedProfile({ ...published, scopes }) + const unchanged = await initializeAgent({ ...initInput(['plan']), scopes }, { + store: new FileSessionStore(directory), + fetcher: again.fetcher, + update: true, + }) + assert.equal(unchanged.action, 'unchanged') + assert.deepEqual(again.writes, []) +}) + it('introduces each space once, on its first index that actually arrived', async () => { // Startup advice about a space can only be given once the space has been read — the registry lives // in it, not in the config — and a warning repeated every tick is a warning nobody reads. diff --git a/packages/daemon/test/merge-poll.test.mjs b/packages/daemon/test/merge-poll.test.mjs --- a/packages/daemon/test/merge-poll.test.mjs +++ b/packages/daemon/test/merge-poll.test.mjs @@ -31,6 +31,7 @@ } const PROJECT_URI = `at://did:plc:human/${COLLECTIONS.project}/project` +const SPACE_URI = `at://did:plc:human/${COLLECTIONS.space}/space` // `members` is the list of DIDs that hold an active grant in this (single-space) index. function makeIndex({ @@ -43,6 +44,9 @@ gitUrl = 'https://tangled.org/@alice/widget', } = {}) { return { + // The space this index is of: every capability question is asked per space, so the writing + // actor is selected against this URI (a profile may be scoped out of implementations here). + space: { uri: SPACE_URI, cid: 'cid-space', value: {} }, members: members.map((did) => ({ did, active: true, kind: 'agent', role: 'agent' })), // Mirror the real ProjectView/GoalView shapes the poller reads: a goal is skipped when it is // ended or hanging off an archived project, and its project's `gitUrl` rides along @@ -134,12 +138,24 @@ const creates = [] const impl = fakeActor(AGENT_B, ['implementation'], creates) const plan = fakeActor(AGENT_A, ['plan'], creates) - assert.equal(selectWritingActor([plan, impl]).did, AGENT_B) + assert.equal(selectWritingActor([plan, impl], SPACE_URI).did, AGENT_B) // No implementation producer: fall back to lowest DID. const planA = fakeActor(AGENT_A, ['plan'], creates) const reviewB = fakeActor(AGENT_B, ['review'], creates) - assert.equal(selectWritingActor([reviewB, planA]).did, AGENT_A) - assert.equal(selectWritingActor([]), undefined) + assert.equal(selectWritingActor([reviewB, planA], SPACE_URI).did, AGENT_A) + assert.equal(selectWritingActor([], SPACE_URI), undefined) + }) + + it('does not treat a profile scoped out of this space as its implementer', () => { + const creates = [] + // Globally an implementer, but scoped to plan-only HERE: the merge annotation goes to the other + // one, even though it sorts later by DID. + const scopedOut = fakeActor(AGENT_A, ['implementation'], creates) + scopedOut.spaces = { [SPACE_URI]: { artifactTypes: ['plan'] } } + const impl = fakeActor(AGENT_B, ['implementation'], creates) + assert.equal(selectWritingActor([scopedOut, impl], SPACE_URI).did, AGENT_B) + // In a space it does not scope, it is an implementer like any other — and lowest DID wins. + assert.equal(selectWritingActor([scopedOut, impl], 'at://did:plc:other/space/two').did, AGENT_A) }) }) diff --git a/packages/daemon/test/private-dispatch.test.mjs b/packages/daemon/test/private-dispatch.test.mjs --- a/packages/daemon/test/private-dispatch.test.mjs +++ b/packages/daemon/test/private-dispatch.test.mjs @@ -28,7 +28,14 @@ import { ClaimManager } from '../dist/claims.js' import { TurnLedger } from '../dist/ledger.js' import { selectDispatchable } from '../dist/dispatch.js' -import { PrivateSpaceRuntime, privateActorRegistry, privateWritersFor } from '../dist/private-space.js' +import { + PrivateSpaceRuntime, + privateActorRegistry, + privateWritersFor, + reconcileCapabilities, +} from '../dist/private-space.js' +import { actorTypesFor } from '../dist/actors.js' +import { coverageAdvisories } from '../dist/cli.js' const ROOT = 'did:plc:privateroot' const AGENT_A = 'did:plc:agenta' @@ -182,7 +189,9 @@ const actor = { did, profile: 'planner', - artifactTypes: ['plan'], + handleName: 'planner', + artifactTypes: options.artifactTypes ?? ['plan'], + ...(options.spaces ? { spaces: options.spaces } : {}), harness: 'claude', models: [], session: { did, handle: `${id}.test`, profile: 'planner' }, @@ -191,7 +200,8 @@ const base = { all: [actor], byDid: new Map([[did, [actor]]]), - select: (candidate, type) => (candidate === did && type === 'plan' ? actor : undefined), + select: (candidate, type, spaceUri) => + candidate === did && actorTypesFor(actor, spaceUri).includes(type) ? actor : undefined, } const registry = privateActorRegistry(base, { writerFor: privateWritersFor(runtime, (candidate) => @@ -237,7 +247,7 @@ }, /** What a turn does, reduced to its only protocol-visible effect: it writes the artifact. */ async fulfil(item) { - const written = await registry.select(did, 'plan').client.create(COLLECTIONS.artifact, { + const written = await registry.select(did, 'plan', SPACE_URI).client.create(COLLECTIONS.artifact, { $type: COLLECTIONS.artifact, request: { uri: item.request.uri, cid: item.request.cid }, goal: { uri: item.target.target.uri, cid: item.target.target.cid }, @@ -419,6 +429,102 @@ ) assert.deepEqual(empty.all, []) assert.equal(empty.select(AGENT_A, 'plan'), undefined) + } finally { + close() + } + }) +}) + +// ── capabilities in a private space (design §11, §18) ─────────────────────────────────────────── +// +// `radiald init` publishes an agent record to a PDS, and a private fold reads `agent` only out of an +// envelope — so before this, `index.agents` was empty in every private space: no assignee list, and +// a coverage advisory at every startup naming a command that could not have helped. The daemon +// publishes them into the replica instead, which is what these three assertions are about: it lands, +// it is idempotent, and a config change republishes under the same rkey for every peer to fold. + +describe('agent capabilities in a private space', () => { + it('publishes them into the replica, silences the coverage advisory, and repeats nothing', async () => { + const { a, close } = await setup() + try { + await a.tick() + // Before: the space's registry has `plan` and nothing in the fold accepts it. + const before = a.index() + assert.deepEqual(before.agents, []) + assert.match( + coverageAdvisories(before, a.registry, { spaceUri: SPACE_URI, configPath: '/etc/radial.json' })[0], + /the registry has a type no agent in the space accepts — plan/, + ) + // And the advisory says the right thing for a private space: `radiald init --update` writes to + // a PDS, which this fold will never read. + assert.match( + coverageAdvisories(before, a.registry, { spaceUri: SPACE_URI, configPath: '/etc/radial.json' })[0], + /restart this daemon/, + ) + + const published = await reconcileCapabilities({ spaceUri: SPACE_URI, actors: a.registry }) + assert.deepEqual(published, [{ did: AGENT_A, profile: 'planner', action: 'created' }]) + const after = a.index() + assert.equal(after.agents.length, 1) + assert.equal(after.agents[0].did, AGENT_A) + assert.equal(after.agents[0].uri.endsWith('/planner'), true, 'keyed by profile name') + assert.deepEqual(after.agents[0].value.artifactTypes, ['plan']) + // Resolved for this space and sealed flat: a replica is per-space, so scoping has nothing to + // say inside one. + assert.equal(after.agents[0].value.scopes, undefined) + assert.deepEqual( + coverageAdvisories(after, a.registry, { spaceUri: SPACE_URI, configPath: '/etc/radial.json' }), + [], + ) + + // Idempotent: an unstable comparison here would seal an envelope every tick, forever, and + // every peer would keep every revision. + const envelopes = a.runtime.envelopes.all().length + const again = await reconcileCapabilities({ spaceUri: SPACE_URI, actors: a.registry }) + assert.deepEqual(again, [{ did: AGENT_A, profile: 'planner', action: 'unchanged' }]) + assert.equal(a.runtime.envelopes.all().length, envelopes) + } finally { + close() + } + }) + + it('republishes on a config change, under the same rkey, and peers fold the latest revision', async () => { + const { a, b, close } = await setup() + try { + await a.tick() + await reconcileCapabilities({ spaceUri: SPACE_URI, actors: a.registry }) + const [record] = a.index().agents + + // The operator edits radial.json and restarts: same profile, a wider list. + a.registry.all[0].artifactTypes = ['plan', 'review'] + const published = await reconcileCapabilities({ spaceUri: SPACE_URI, actors: a.registry }) + assert.deepEqual(published, [{ did: AGENT_A, profile: 'planner', action: 'updated' }]) + const [republished] = a.index().agents + assert.equal(republished.uri, record.uri, 'the same rkey, so nothing loses its assignee') + assert.notEqual(republished.cid, record.cid) + assert.deepEqual(republished.value.artifactTypes, ['plan', 'review']) + + // The other daemon folds the latest revision off the bus, not the first one. + await b.tick() + assert.deepEqual( + b.index().agents.map((agent) => agent.value.artifactTypes), + [['plan', 'review']], + ) + } finally { + close() + } + }) + + it('seals the effective list for THIS space when the profile is scoped', async () => { + const { a, close } = await setup({ + a: { artifactTypes: ['plan', 'implementation'], spaces: { [SPACE_URI]: { artifactTypes: ['plan'] } } }, + }) + try { + await a.tick() + await reconcileCapabilities({ spaceUri: SPACE_URI, actors: a.registry }) + // The override, resolved — `implementation` is what this profile does elsewhere, and the + // replica has no way and no reason to know that. + assert.deepEqual(a.index().agents[0].value.artifactTypes, ['plan']) } finally { close() } diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.agent.json b/packages/lexicons/lexicons/com.disnetdev.radial.agent.json --- a/packages/lexicons/lexicons/com.disnetdev.radial.agent.json +++ b/packages/lexicons/lexicons/com.disnetdev.radial.agent.json @@ -10,6 +10,14 @@ "costHint": {"type": "string", "maxLength": 100} } }, + "scope": { + "type": "object", + "required": ["space", "artifactTypes"], + "properties": { + "space": {"type": "string", "format": "at-uri"}, + "artifactTypes": {"type": "array", "maxLength": 100, "items": {"type": "string", "maxLength": 100}} + } + }, "main": { "type": "record", "key": "any", @@ -21,6 +29,7 @@ "harness": {"type": "string", "maxLength": 200}, "models": {"type": "array", "maxLength": 50, "items": {"type": "ref", "ref": "#model"}}, "artifactTypes": {"type": "array", "maxLength": 100, "items": {"type": "string", "maxLength": 100}}, + "scopes": {"type": "array", "maxLength": 50, "items": {"type": "ref", "ref": "#scope"}}, "createdAt": {"type": "string", "format": "datetime"} } } diff --git a/packages/core/src/generated/records.ts b/packages/core/src/generated/records.ts --- a/packages/core/src/generated/records.ts +++ b/packages/core/src/generated/records.ts @@ -56,12 +56,15 @@ export type AgentModel = { name: string; costHint: string } +export type AgentScope = { space: string; artifactTypes: Array } + export interface AgentRecord { $type: "com.disnetdev.radial.agent" handleName: string harness: string models: Array artifactTypes: Array + scopes?: Array createdAt: string } @@ -419,6 +422,27 @@ } } }, + "scope": { + "type": "object", + "required": [ + "space", + "artifactTypes" + ], + "properties": { + "space": { + "type": "string", + "format": "at-uri" + }, + "artifactTypes": { + "type": "array", + "maxLength": 100, + "items": { + "type": "string", + "maxLength": 100 + } + } + } + }, "main": { "type": "record", "key": "any", @@ -454,6 +478,14 @@ "items": { "type": "string", "maxLength": 100 + } + }, + "scopes": { + "type": "array", + "maxLength": 50, + "items": { + "type": "ref", + "ref": "#scope" } }, "createdAt": { diff --git a/packages/ui/src/lib/directory.ts b/packages/ui/src/lib/directory.ts --- a/packages/ui/src/lib/directory.ts +++ b/packages/ui/src/lib/directory.ts @@ -16,7 +16,7 @@ // and `Disc.svelte` shows the face instead — same size, same shape, so the row does not move. The // colour and initials are computed either way: they are the fallback the moment the image fails. -import type { MaterializedIndex } from '@radial/core' +import { agentTypesFor, type MaterializedIndex } from '@radial/core' export interface Actor { did: string @@ -30,7 +30,12 @@ color: string /** The profile picture this identity publishes, when something has resolved one. */ avatar?: string - /** For an agent, the artifact types it accepts — what an assignee picker filters on. */ + /** + * For an agent, the artifact types it accepts IN THIS SPACE — what an assignee picker filters on. + * + * Effective types, not the record's global list: a profile may scope itself per space (design §11), + * and a picker that offered a type the agent has scoped out here would assign work that then sits. + */ artifactTypes: string[] active: boolean } @@ -98,10 +103,16 @@ for (const agent of index.agents) { const existing = agentRecords.get(agent.did) // One DID may publish several profiles (one record per profile); the union of what they accept - // is what that agent can be asked for. + // is what that agent can be asked for — a union of the EFFECTIVE lists, so a profile scoped out + // of this space contributes nothing to it and one scoped into it contributes its scope. agentRecords.set(agent.did, { handle: existing?.handle ?? agent.value.handleName, - artifactTypes: [...new Set([...(existing?.artifactTypes ?? []), ...agent.value.artifactTypes])], + artifactTypes: [ + ...new Set([ + ...(existing?.artifactTypes ?? []), + ...agentTypesFor(agent.value, index.space.uri), + ]), + ], }) } diff --git a/packages/ui/src/lib/requests.test.ts b/packages/ui/src/lib/requests.test.ts --- a/packages/ui/src/lib/requests.test.ts +++ b/packages/ui/src/lib/requests.test.ts @@ -1,6 +1,7 @@ import { FIXTURE_DIDS } from '@radial/core/fixture' import { describe, expect, it } from 'vitest' import { closeDraft, compose, openDraft } from './compose.svelte.js' +import { buildDirectory } from './directory.js' import { buildFixtureSpace } from './fixture.js' import type { UnitVersion } from '@radial/core' import { @@ -505,6 +506,30 @@ it('offers nobody for a type no agent has taken on, rather than offering a dead end', () => { expect(capableAgents(space.directory, 'threat-model')).toEqual([]) + }) + + it('reads a profile scoped to THIS space, and ignores one scoped to another', () => { + // The same published record in both cases: `artifactTypes` unchanged, an overlay naming a space. + const scoped = (space_: string) => + buildDirectory({ + ...space.index, + agents: space.index.agents.map((agent) => + agent.value.handleName.startsWith('planner') + ? { + ...agent, + value: { ...agent.value, scopes: [{ space: space_, artifactTypes: ['plan'] }] }, + } + : agent, + ), + }) + // Scoped here: the picker stops offering the planner for everything but `plan`. Offering it + // would be offering a dead end — the daemon reads the same override and would not dispatch. + const here = scoped(space.index.space.uri) + expect(capableAgents(here, 'plan').map((agent) => agent.name)).toEqual(['planner']) + expect(capableAgents(here, 'conventions')).toEqual([]) + // Scoped somewhere else: inert, and the global list is what applies. + const elsewhere = scoped('at://did:plc:nobody/com.disnetdev.radial.space/other') + expect(capableAgents(elsewhere, 'conventions').map((agent) => agent.name)).toEqual(['planner']) }) }) diff --git a/packages/ui/src/lib/requests.ts b/packages/ui/src/lib/requests.ts --- a/packages/ui/src/lib/requests.ts +++ b/packages/ui/src/lib/requests.ts @@ -377,6 +377,10 @@ * actually answer — a request named at an agent that does not produce the type is one the daemon * rejects on dispatch, and offering it would be offering a dead end. Leaving it unassigned is always * available, and is what lets any capable daemon claim it. + * + * `Actor.artifactTypes` is already the EFFECTIVE list for the space the directory was built over + * (`buildDirectory`), so a profile scoped down here is filtered out here — one resolution of the + * override rule, applied where the record is read rather than where it is offered. */ export function capableAgents(directory: Directory, type: string): Actor[] { return directory -- tangled.sh