Resources, the one list each session uses, groupings that only show, one policy, workers that follow their list, and the screens on top, with every piece in code
The third edition, rewritten after the owner's code review of 2026-11-08 cut the abstractions of the second (resource
kinds, built-ins, grants, resource objects). The model is the owner's decision "Resource model direction"
(apps/p3/docs/decisions.md); the review by Astra and Sol of the second edition (appendix C) still applies, its fixes
carried over. Chord APIs are real and checked against the source (self.tx(fn, ctx), tx.put, self.on,
self.derive, keyed members, registries with their contributing plugin); every p3 name is a proposal. Not built.
Mario runs p3 on his laptop. Every session he opens has his models, his MCP servers, his machines and every tool the installed plugins offer, and he never configured any of it. At Earendil, Colin, Cristina and Charlotta share project pi: its sessions use exactly what the three brought, each with its owner's login. A session fed by the #support Discord channel may read the answers of one other session for an hour and has no shell at all.
Two questions were mixed up in every earlier model: where does something appear and what may a session use. Groupings answer the first: recent, folders, projects, Discord channels, any number per session. One list answers the second, and one field in the session catalog says where that list comes from.
defineResource("mcp.server") in the owning plugin's contract file; what the row means stays inside that plugin.Resources.create, owned by
Colin forever, with Colin's login).A user is a person with an account; the instance user (INSTANCE) owns company resources and everything
plugins bring. A resource definition is a token made by defineResource(name). A resource is one row: the
definition's name plus an id (null for the single resource a plugin brings). A list holds entries (a
resource, who brought it, an optional expiry). A session's resource source names its list: defaults, own,
or a grouping with resources. The effective list is what the session actually gets after core drops withdrawn,
expired, missing and denied entries. A grouping shows things; a grouping with resources also keeps a list. The
policy answers typed requests. A creator is a resource definition's creation sheet; a resource row and
resource panels draw one resource.
One row per resource, one function to define a name, and two ways a resource comes to exist.
A resource is something a session can use. The model needs four facts about it, and nothing else:
That is one row in one table of the resources plugin. There is no resource object with state and methods: when a resource has state (an MCP server's configuration, its sign-in, its tools), that state belongs to the plugin that made it, keyed by the resource's id.
// plugins/resources/contract.ts: shared by every facet (server, worker, page)
/** A resource definition: a name a plugin owns, written once in its contract file. */
export type ResourceDefinition = { readonly name: string; readonly id: null };
export function defineResource(name: string): ResourceDefinition;
/** One resource as stored, listed and sent: its definition's name and, for created ones, an id. */
export const ResourceRef = obj({ name: str(), id: nullable(str()) });
export type ResourceRef = Static<typeof ResourceRef>;
/** A created resource of `definition` by its id. */
export function resourceOf(definition: ResourceDefinition, id: string): ResourceRef;
/** Whether `ref` is a resource of `definition`. */
export function isOf(ref: ResourceRef, definition: ResourceDefinition): boolean;
export function sameResource(a: ResourceRef, b: ResourceRef): boolean;
defineResource returns { name, id: null }, so a definition is also the reference to the one resource a plugin
brings with it. Names are identities written once, like contract names; code passes the constant.
// plugins/coding-tools/contract.ts
export const Bash = defineResource("coding-tools.bash");
export const FileTools = defineResource("coding-tools.files");
// plugins/coding-tools/server.ts
export default defineFacet((host, self) => {
self.contribute(InstanceResources, [Bash, FileTools]);
});
InstanceResources is a server registry of the resources plugin. A contribution is in a Chord registry exactly while
its contributor is live (design §17): installed, Bash exists, owned by INSTANCE; uninstalled or failed, it is gone
and drops out of every list. Nothing is written, nobody is the actor: installing a plugin is the admin's act.
Colin adds his Linear server. The MCP plugin's own user view does the work and calls the resources plugin for the row:
// plugins/mcp/server.ts (full plugin in chapter VIII)
create: async ({ owner, config }, ctx) => {
const resource = await resources.create({ actor: principal.id, owner, definition: McpServer, label: config.name }, ctx);
await store.add({ id: resource.id, config }, ctx);
return resource;
},
Actor and owner differ only when an admin creates a company resource: the actor is the admin, the owner is
INSTANCE. Everyone else creates for themselves. actor is passed as data because a local call runs as the server
(design §4); it is always the link-stamped principal of the plugin's own user-facing method, never browser input.
// plugins/resources/contract.server.ts: server-only declarations other plugins use
export const InstanceResources = defineRegistry<readonly ResourceDefinition[], BroughtResources>(
"p3.instance-resources", { version: 1 });
export const Resources = local("p3.resources", {
version: 1,
params: {},
members: {
/** Every resource: created rows and brought ones, by resource key. */
index: map(IndexRow), // { resource, owner, label, revision }
/** Each session's own list, by session id. */
ownLists: keyed(arr(ListEntry)),
/** Each user's exclusions from their defaults, by user id. */
exclusions: map(arr(ResourceRef)),
/** The guarded step for a new resource: the owner rule, the policy, then the row. */
create: method(obj({ actor: str(), owner: str(), definition: ResourceRef, label: str() }), ResourceRef),
/** The owning plugin deleted the resource. */
forget: method(obj({ resource: ResourceRef })),
},
});
// plugins/resources/server.ts
const CreatedRows = defineTable("resources.created", {
version: 1,
columns: { name: str(), id: str(), owner: str(), label: str(), revision: int() },
key: ["name", "id"],
});
export default defineFacet((host) => {
host.serve(InstanceResources, { compose: (table, { report }) => byName(table, report) }); // a name twice: reported
host.serve(Resources, (_params, self) => {
const created = self.table(CreatedRows);
const createdIndex = self.state(CreatedIndex); // in memory: the table's rows as a map, filled at activation
const brought = self.registry(InstanceResources);
const policy = self.registry(Policies);
const identities = self.get(Identities, {}); // users, admins (chapter II)
const ownLists = self.state(OwnLists); // persisted, keyed by session id
const exclusions = self.state(Exclusions); // persisted: user id -> refs
// One index: created rows plus brought resources (owner INSTANCE, revision 0).
const index = self.derive([createdIndex, brought], ([rows, bringing]) => ({ ...rows, ...broughtRows(bringing) }));
return {
index,
ownLists,
exclusions,
create: async ({ actor, owner, definition, label }, ctx) => {
// 1. You create only for yourself; admins also for the company. No policy changes this.
if (owner !== actor && !(owner === INSTANCE && isAdmin(identities.users.value, actor)))
throw forbidden(messages.message("notYourResource"));
// 2. The policy, e.g. "only engineering creates MCP servers".
const decision = policy.value.decide({ kind: "create-resource", actor, definition, owner }, ALLOW);
if (decision.kind === "deny") throw forbidden(decision.reason);
// 3. The row, and the in-memory index the lists read.
const id = newId();
await self.tx(async (tx) => {
tx.put(created, { name: definition.name, id, owner, label, revision: 0 });
(await tx.draft(createdIndex)).rows[key(definition.name, id)] = { owner, label, revision: 0 };
}, ctx);
return { name: definition.name, id };
},
forget: async ({ resource }, ctx) => { /* delete the row and its index entry; lists drop it */ },
};
});
});
self.tx(fn, ctx) is the object's transaction for this call: its handles (created, createdIndex) belong to the
object, ctx to the call; one commit, cancelled if the caller goes away.
Who a user is, how a person becomes one, admins, the instance user, sign-in plugins, per-user data, and what happens when someone leaves.
A user is a person with an account on this p3 instance. Everything in the model hangs off users: a resource's owner is a user, a session's owner is a user, a project's members are users (or groupings of users), the policy decides per user. Users are kept small on purpose: an id the principal carries, a profile, the identities a person signs in with, and an admin flag. Everything else about a person (their recent sessions, their exclusions, their picks) is data of the plugin that needs it, keyed by the user id.
p3 builds on the app kit's users and sessions rather than beside them (design §23): the kit's defineUsers(profile)
keeps profiles by id, the kit's Sessions issues and revokes sign-in sessions, the kit's Authenticators registry
turns a request into a principal. p3's users plugin adds what the kit leaves to the app: how an external identity maps
to a user, who is an admin, and the instance user.
// plugins/users/contract.ts: shared by every facet
export type UserId = string & { readonly "~user": true };
/** The instance user: owns company resources and everything plugins bring. Never signs in, never in the user list. */
export const INSTANCE: UserId;
/** p3's principals (decision 2026-11-06); links stamp them, a caller's data never sets one. */
export type P3Principal =
| { readonly kind: "server" }
| { readonly kind: "worker"; readonly session: string }
| { readonly kind: "user"; readonly id: UserId }
| { readonly kind: "guest" }; // reserved until sharing is designed
/** What every user's profile holds (the kit's users plugin stores it by id). */
export const Profile = obj({ name: str(), email: nullable(str()), avatar: nullable(str()) });
A principal names the user and nothing else: no name, no groups, no admin flag (design §4: objects persist a projection, never the principal). A changed name or admin flag never needs a new session.
// plugins/users/contract.server.ts: server-only declarations other plugins use
/** The kit's users plugin, instantiated with p3's profile (the API re-exports it; design §23). */
export const users = defineUsers(Profile); // users.Users: list, get, update, remove
export const Identities = local("p3.users.identities", {
version: 1,
params: {},
members: {
/** Every user with their admin flag; the kit's profile list joined with p3's facts. */
users: arr(obj({ id: str(), profile: Profile, admin: bool(), disabled: bool() })),
/** The user an external identity signs in as; creates the user at its first sign-in. Never returns INSTANCE. */
signIn: method(obj({ issuer: str(), subject: str(), profile: Profile }), str()),
/** Adds another identity to an existing user (a second sign-in method). */
link: method(obj({ actor: str(), user: str(), issuer: str(), subject: str() })),
/** Makes a user an admin or not; asks the policy for `administer`. */
setAdmin: method(obj({ actor: str(), user: str(), admin: bool() })),
/** Disables a user: their sessions end, their resources stop counting (below). */
disable: method(obj({ actor: str(), user: str() })),
},
});
// plugins/users/server.ts
const IdentityRows = defineTable("users.identities", {
version: 1,
columns: { issuer: str(), subject: str(), user: str() },
key: ["issuer", "subject"],
});
const Facts = defineState("users.facts", {
schema: obj({ admins: arr(str()), disabled: arr(str()) }),
version: 1,
initial: () => ({ admins: [], disabled: [] }),
persist: true,
checkpointWhen: () => true,
});
export default defineFacet((host) => {
host.serve(Identities, (_params, self) => {
const identities = self.table(IdentityRows);
const facts = self.state(Facts);
const profiles = self.get(users.Users, {}); // the kit's profiles by id
const policy = self.registry(Policies);
const sessions = self.get(Sessions, {}); // the kit's sign-in sessions
const list = self.derive([profiles.list, facts], ([all, f]) =>
all.map((u) => ({ id: u.id, profile: u.profile, admin: f.admins.includes(u.id), disabled: f.disabled.includes(u.id) })));
const administers = (actor: string) =>
policy.value.decide({ kind: "administer", actor: userId(actor) }, allowIf(facts.value.admins.includes(actor))).kind === "allow";
return {
users: list,
signIn: async ({ issuer, subject, profile }, ctx) => {
let id = "";
await self.tx(async (tx) => {
const found = await tx.get(identities, { issuer, subject });
if (found !== undefined) { id = found.user; return; }
id = newUserId();
tx.put(identities, { issuer, subject, user: id });
const f = await tx.draft(facts);
if (profiles.list.value.length === 0) f.admins.push(id); // the first user administers
}, ctx);
if (facts.value.disabled.includes(id)) throw forbidden(messages.message("userDisabled"));
await profiles.update({ id, profile }, ctx); // name and email follow the identity provider
return id;
},
link: async ({ actor, user, issuer, subject }, ctx) => {
if (actor !== user && !administers(actor)) throw forbidden(messages.message("notYou"));
await self.tx(async (tx) => tx.put(identities, { issuer, subject, user }), ctx);
},
setAdmin: async ({ actor, user, admin }, ctx) => {
if (!administers(actor)) throw forbidden(messages.message("adminsOnly"));
await facts.change((f) => { f.admins = admin ? [...new Set([...f.admins, user])] : f.admins.filter((a) => a !== user); }, ctx);
},
disable: async ({ actor, user }, ctx) => {
if (!administers(actor)) throw forbidden(messages.message("adminsOnly"));
await facts.change((f) => { f.disabled.push(user); }, ctx);
for (const s of sessions.list.value) if (isUser(s.principal, user)) await sessions.revoke(s.id, ctx);
},
};
});
});
Every way in is a plugin that verifies something, calls Identities.signIn, and issues a kit session for the
principal it got back. Nothing else creates users.
signIn({ issuer: "p3.owner", subject: "owner", ... }) at first start), so the link,
the owner token file and device pairing sign in as that user (Chord decision "The kit's owner is a principal the app
chooses").// plugins/saml/server.ts
self.contribute(Http, { routes: { "POST /saml/acs": async (request, ctx) => {
const assertion = await verify(await request.body(), idp.value, ctx);
const id = await identities.signIn({ issuer: assertion.issuer, subject: assertion.subject,
profile: { name: assertion.name, email: assertion.email, avatar: null } }, ctx);
await directory.mirror({ user: id, groups: assertion.groups }, ctx); // groupings for the policy (chapter XII)
const session = await sessions.issue({ principal: { kind: "user", id }, label: assertion.email ?? assertion.name, kind: "browser", secure: true }, ctx);
return { kind: "redirect", to: "/", headers: { "set-cookie": session.cookie } };
} } });
Emails are never identities: two providers may report the same address for different people, and a person may change
theirs. Linking a second identity to an existing user is explicit (link), by the user themselves or an admin.
administer request, so a company policy can say "members of the SAML group admins administer" instead. Admins
create company resources (owner INSTANCE), install plugins, edit rules, disable users.signIn, shown as "the company".
It owns what plugins bring and what admins create for everyone. Nobody acts as it: an admin creating the company
key is the actor, the instance the owner.// plugins/users/contract.ts
/** Myself: my profile, whether I administer, my sign-in sessions (devices) with a sign-out each. */
export const Me = remote("p3.users.me", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
user: {
state: { me: obj({ id: str(), profile: Profile, admin: bool() }), sessions: arr(SessionSummary) },
methods: { rename: method(obj({ name: str() })), signOut: method(obj({ session: str() })) },
},
},
});
/** The people of this instance, for pickers (add a member, share a session); names only, no emails for non-admins. */
export const People = remote("p3.users.people", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { user: { state: { people: arr(obj({ id: str(), name: str(), avatar: nullable(str()) })) } } },
});
/** Admins: every user with flags and identities, and the methods behind the users screen. */
export const UserAdmin = remote("p3.users.admin", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
admin: { state: { users: arr(AdminUserRow) }, methods: { setAdmin: method(obj({ user: str(), admin: bool() })), disable: method(obj({ user: str() })) } },
},
});
UserAdmin is served only to principals that administer (the factory asks the policy and refuses others); its methods
pass the link-stamped principal as actor to Identities. A user who loses admin while a page holds the view gets
refused calls; the view is made again on the next connection.
The users plugin does not grow a settings bag. Each plugin keys its own data by user id and serves it through a
per-user view: recent sessions in the recent plugin, exclusions from defaults in the resources plugin (MyResources),
model defaults in the models plugin, the theme and language in the kit's preferences. A user's data is found by
asking the plugin that owns it, never through the users plugin.
disable is the admin's act; the effects follow from facts other plugins already read:
signIn refuses the user's identities.usable drops entries whose owner is disabled, so
Colin's Linear server leaves project pi's effective list the moment Colin is disabled. The rows stay, so an admin
can re-enable without anyone re-bringing anything.revokedBy to the
server host (design §4, { user: id }); today the kit revokes sessions only, so open views end on the next
reconnect. A small kit change, recorded as carry-over.Dropping a disabled user's resources from every list is the safe default: their logins are theirs. A company that wants Colin's MCP server to keep running after he leaves moves it to the company by re-creating it as an instance resource (a second resource, the company's login), never by re-assigning his.
The session's one resource source, the entries, how core computes a session's list, and bringing, withdrawing and locking down.
// plugins/resources/contract.ts
export const GroupingRef = obj({ name: str(), id: str() }); // a grouping definition's name and the grouping's id
export const ResourceSource = union([
obj({ kind: lit("defaults") }), // everything the owner owns + instance, minus exclusions
obj({ kind: lit("own") }), // the session's own list
obj({ kind: lit("grouping"), grouping: GroupingRef }), // a project, a Discord channel
]);
// plugins/sessions/contract.server.ts: the catalog row, kept by the sessions plugin
export const SessionRow = obj({ id: str(), owner: str(), title: str(), source: ResourceSource, /* location, status, ... */ });
export const SessionCatalog = local("p3.sessions.catalog", {
version: 1,
params: {},
members: { rows: keyed(SessionRow), setSource: method(obj({ actor: str(), session: str(), source: ResourceSource })) },
});
One field, so one source, by construction. Moving a session into another project replaces the field.
export const ListEntry = obj({
resource: ResourceRef,
broughtBy: str(), // stamped by core, never caller data
until: nullable(int()), // an expiry (ms since the epoch); null: until removed
revision: int(), // the resource's revision when brought; a withdrawal raises it
});
/** What a session gets: ready with entries, or unavailable (fail closed). */
export const SessionListSchema = union([
obj({ kind: lit("ready"), entries: arr(obj({ resource: ResourceRef, owner: str(), until: nullable(int()) })) }),
obj({ kind: lit("unavailable"), reason: MessageSchema }),
]);
No login field, no mode: the owner of the resource decides its login, forever.
One local object per session, alive while something holds it: the session worker's view and every resource plugin's per-session server view hold the same one.
// plugins/resources/contract.server.ts
export const SessionList = local("p3.resources.session-list", {
version: 1,
params: { session: str() },
members: { list: SessionListSchema },
});
// plugins/resources/server.ts
host.serve(SessionList, ({ session }, self) => {
const row = self.get(SessionCatalog, {}).rows.at(session); // live: owner, source
const resources = self.get(Resources, {});
const own = resources.ownLists.at(session); // live: this session's own list
const groupings = self.registry(Groupings); // how to read a grouping's list (chapter IV)
const policy = self.registry(Policies);
const fromGrouping = self.state(SourceEntries); // in memory: { entries: ListEntry[] | "absent" | null }
const clock = self.state(Clock); // in memory: { now }, advanced at the next expiry
// A grouping's id is known only from the row's state, so its list is watched here, not pinned in the factory.
// Routine calls never overlap and a change does not abort a running call (design §14), so the watch runs under
// a child context that `untilSourceChanges` aborts when the row stops naming this grouping; the next call then
// starts with the new source.
self.on(row, async ({ value }, ctx) => {
if (value.source.kind !== "grouping") return fromGrouping.change((d) => { d.entries = null; }, ctx);
const source = groupings.value.get(value.source.grouping.name)?.resources;
if (source === undefined) return fromGrouping.change((d) => { d.entries = "absent"; }, ctx); // plugin not loaded
const named = value.source.grouping;
const scoped = untilSourceChanges(row, named, ctx); // withAbortSignal(controller.signal, ctx), aborted on change
for await (const entries of source.lists.watch(named.id, scoped))
await fromGrouping.change((d) => { d.entries = entries; }, ctx);
});
const list = self.derive(
[row, own, resources.index, resources.exclusions, fromGrouping, policy, clock],
([r, ownEntries, index, excluded, grouping, current, time]) => {
const entries =
r.source.kind === "defaults" ? defaultsOf(r.owner, index, excluded[r.owner] ?? [])
: r.source.kind === "own" ? ownEntries
: grouping.entries;
if (entries === "absent" || entries === null)
return { kind: "unavailable", reason: messages.message("sourceUnavailable") };
return { kind: "ready", entries: entries.flatMap((entry) => usable(entry, r, index, current, time.now)) };
},
);
// The clock: sleep until the earliest `until` on the list, then advance, so an entry drops out on time.
self.on(list, async ({ value }, ctx) => {
const next = earliestUntil(value);
if (next === null) return;
await sleep(Math.max(0, next - Date.now()), ctx);
await clock.change((d) => { d.now = Date.now(); }, ctx);
});
return { list };
});
/** Defaults: every resource the owner owns plus every instance resource, minus the owner's exclusions. */
function defaultsOf(owner: string, index: Index, excluded: readonly ResourceRef[]): ListEntry[] {
return Object.values(index)
.filter((row) => (row.owner === owner || row.owner === INSTANCE) && !excluded.some((x) => sameResource(x, row.resource)))
.map((row) => ({ resource: row.resource, broughtBy: owner, until: null, revision: row.revision }));
}
/** One entry as a session gets it, or nothing: missing, withdrawn since, expired, or denied by the policy. */
function usable(entry: ListEntry, row: SessionRow, index: Index, policy: Policy, now: number) {
const indexed = index[keyOf(entry.resource)];
if (indexed === undefined) return []; // its plugin is gone, or it was deleted
if (disabled(indexed.owner)) return []; // its owner left (chapter II)
if (entry.revision < indexed.revision) return []; // withdrawn by its owner since
if (entry.until !== null && entry.until <= now) return []; // expired
const use = policy.decide({ kind: "use", session: row, resource: entry.resource, owner: indexed.owner }, useBaseline(entry.resource, row));
if (use.kind === "deny") return [];
return [{ resource: entry.resource, owner: indexed.owner, until: entry.until }];
}
Every input is live state; Chord recomputes list when any of them changes and pushes it to whoever holds it. The
clock only makes the derive run again at a deadline: every call admission (chapter VI) also compares until with
the current time, so a late timer never extends access. useBaseline is allow, except for resources whose definition
asks for more: the local machine and server-side stdio need the session's owner to administer (chapter XI).
People change lists through core's per-user view, so the actor is the link-stamped principal:
// plugins/resources/contract.ts
export const ListRef = union([obj({ kind: lit("session"), session: str() }), obj({ kind: lit("grouping"), grouping: GroupingRef })]);
export const MyResources = remote("p3.resources.mine", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
user: {
state: {
owned: arr(IndexRow), // my resources
excluded: arr(ResourceRef), // my exclusions from defaults
creatable: arr(Creatable), // { owner: me | INSTANCE (admins), definitions: names }
},
methods: {
candidates: method(obj({ into: ListRef }), arr(Candidate)), // what I may bring here, with the longest allowed `until`
bring: method(obj({ resource: ResourceRef, into: ListRef, until: nullable(int()) })),
remove: method(obj({ resource: ResourceRef, from: ListRef })),
withdraw: method(obj({ resource: ResourceRef })),
exclude: method(obj({ resource: ResourceRef, excluded: bool() })),
setSource: method(obj({ session: str(), source: ResourceSource })),
lockDown: method(obj({ session: str() })),
},
},
},
});
mayBring,
chapter IV). Then the policy sees { kind: "bring", actor, resource, into, until }. Core stamps broughtBy and the
resource's current revision.revision in the index first, so
every entry brought before stops counting at once, also in a grouping whose plugin is reloading and comes back with
its old rows; then it asks each loaded list keeper to delete the entry and retries absent ones when they return. The
owner's own defaults sessions stop using it through exclude.mayJoin);
the policy sees { kind: "set-source", ... }.until kept. Cristina locking down a pi
session cannot keep Colin's server.With "only admins bring instance resources", nobody but an admin can put bash into a project, and a project session has no tools. Proposal: resources a plugin brings carry no login, so anyone who may use one may bring it, and a new grouping's list starts with every brought resource its creator may use. Company resources with a login (the company key, the build machine) stay admin-only.
Groupings show things; the few that carry a list register it with the resources plugin; a project from the inside.
A grouping is any plugin's object that shows things: recent (per user, from what each opened), folders (computed from where sessions work), projects (curated), a Discord channel, a mirrored SAML group. A session appears in any number of them, and appearing grants nothing. Groupings that only show need nothing from core.
A grouping with resources keeps a list in its own plugin and tells the resources plugin how to read and change it, through one server registry:
// plugins/resources/contract.ts
export type GroupingDefinition = { readonly name: string };
export function defineGrouping(name: string): GroupingDefinition;
export function groupingOf(definition: GroupingDefinition, id: string): GroupingRef;
export type MemberRef = { readonly kind: "user"; readonly user: UserId } | { readonly kind: "grouping"; readonly grouping: GroupingRef };
// plugins/resources/contract.server.ts
/** What a grouping plugin tells the resources plugin; groupings that only show contribute nothing. */
export type GroupingRegistration = {
readonly grouping: GroupingDefinition;
/** Who is in each grouping, by grouping id: for who sees its sessions, and for the policy's rules. */
readonly members?: KeyedWatchable<readonly MemberRef[]>;
/** Only on a grouping with resources. */
readonly resources?: {
readonly lists: KeyedWatchable<readonly ListEntry[]>; // by grouping id
mayBring(actor: UserId, id: string): boolean; // the list side of bringing
mayRemove(actor: UserId, id: string): boolean; // maintainers taking something out
mayJoin(actor: UserId, id: string): boolean; // making it a session's source
add(id: string, entry: ListEntry, ctx: Ctx): Promise<void>;
remove(id: string, resource: ResourceRef, ctx: Ctx): Promise<void>;
};
};
export const Groupings = defineRegistry<readonly GroupingRegistration[], ReadonlyMap<string, GroupingRegistration>>(
"p3.groupings", { version: 1 });
The resources plugin reads lists for SessionList, calls mayBring/add from MyResources.bring and remove
from remove and withdraw; core's Members object expands every registration's members (cutting cycles) for
visibility and for policy rules.
// plugins/projects/contract.ts: what the page needs
export const Project = defineGrouping("projects.project");
/** One project as a signed-in member sees it. Actions say what this user may do now (live, not a variant). */
export const ProjectView = remote("projects.view", {
version: 1,
params: { id: str() },
hostAddress: () => Server(),
variants: {
user: {
state: { project: nullable(ProjectState) }, // { title, members, list: ListEntry[], sessions: SessionRef[], actions }
methods: { rename: method(obj({ title: str() })), addMember: method(obj({ member: MemberRefSchema })) },
},
},
});
export const Projects = remote("projects.mine", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { user: { state: { projects: arr(ProjectSummary) }, methods: { create: method(obj({ title: str() }), str()) } } },
});
// plugins/projects/server.ts
const ProjectRows = defineTable("projects.rows", {
version: 1,
columns: { id: str(), title: str(), maintainers: json(arr(str()), { version: 1 }) },
key: ["id"],
});
const Lists = defineState("projects.lists", { schema: arr(ListEntry), version: 1, keyed: true, persist: true, initial: () => [], checkpointWhen: () => true });
const Members = defineState("projects.members", { schema: arr(MemberRefSchema), version: 1, keyed: true, persist: true, initial: () => [], checkpointWhen: () => true });
/** The projects plugin's server store (host lifetime): its tables, its registration, its writes. */
const ProjectStore = local("projects.store", { version: 1, params: {}, members: { /* rows, create, rename, addMember */ } });
export default defineFacet((host) => {
host.serve(ProjectStore, (_params, self) => {
const rows = self.table(ProjectRows);
const lists = self.state(Lists);
const members = self.state(Members);
const access = self.get(Access, {});
const isMember = (user: UserId, id: string) => expanded(members, id).includes(user);
const isMaintainer = (user: UserId, id: string) => maintainersOf(rows, id).includes(user);
self.contribute(Groupings, [{
grouping: Project,
members,
resources: {
lists,
mayBring: isMember,
mayRemove: isMaintainer,
mayJoin: isMember,
add: (id, entry, ctx) => lists.change(id, (draft) => { draft.push(entry); }, ctx),
remove: (id, resource, ctx) => lists.change(id, (draft) => removeEntry(draft, resource), ctx),
},
}]);
return {
create: async ({ actor, title }, ctx) => {
await access.require({ kind: "create-grouping", actor, grouping: Project, baseline: ALLOW }, ctx);
const id = newId();
await self.tx(async (tx) => {
tx.put(rows, { id, title, maintainers: [actor] });
(await tx.draft(members, id)).push({ kind: "user", user: actor });
}, ctx);
return id;
},
addMember: async ({ actor, id, member }, ctx) => {
await access.require({ kind: "add-member", actor, to: groupingOf(Project, id), member, baseline: allowIf(isMaintainer(actor, id)) }, ctx);
await members.change(id, (draft) => { draft.push(member); }, ctx);
},
};
});
// ProjectView and Projects: per-user views over the store and the catalog (sessions whose source is the project).
});
A project's sessions are a query: catalog rows whose source names the project. The session points up, never the project down, so no two projects can both equip a session.
The sessions plugin's catalog view for a user shows a session when the user owns it, it is shared with them (the
session's own people list), or they are a member of its source grouping (Members). Seeing is not every operation:
the sessions plugin keeps mayView, maySend (owner, shared with send, members of the source grouping) and isOwner
per session and user, and uses them as baselines for its typed operations (send, shell, reset, configure, set source).
Linking a pi session to Discord makes it use the channel's list, and it leaves pi. Both reviewers propose that a project may also keep plain "shown here" references to such sessions, granting nothing.
One replaceable policy, typed requests, asked by core at the moment something happens.
// plugins/policy/contract.server.ts: core
export type Decision = { readonly kind: "allow" } | { readonly kind: "deny"; readonly reason: MessageData };
/** Everything core asks. Definitions are compared by name with `isOf`; operations with `isOperation`. */
export type Request =
| { readonly kind: "create-resource"; readonly actor: UserId; readonly definition: ResourceDefinition; readonly owner: UserId }
| { readonly kind: "create-grouping"; readonly actor: UserId; readonly grouping: GroupingDefinition }
| { readonly kind: "bring"; readonly actor: UserId; readonly resource: ResourceRef; readonly owner: UserId; readonly into: ListRef; readonly until: number | null }
| { readonly kind: "set-source"; readonly actor: UserId; readonly session: SessionRow; readonly source: ResourceSource }
| { readonly kind: "add-member"; readonly actor: UserId; readonly member: MemberRef; readonly to: GroupingRef }
| { readonly kind: "use"; readonly session: SessionRow; readonly resource: ResourceRef; readonly owner: UserId }
| { readonly kind: "administer"; readonly actor: UserId }
| { readonly kind: "call"; readonly actor: UserId; match<C extends RemoteContract, M extends MethodName<C>>(op: Operation<C, M>):
{ readonly target: ParamsOf<C>; readonly args: ArgsOf<C, M> } | undefined };
export type Policy = { decide(request: Request, baseline: Decision): Decision };
/** Exactly one provider; zero, two or a failed one compose to deny-all, never to the default. */
export const Policies = defineRegistry<Policy, Policy>("p3.policy", { version: 1 });
/** Asks the policy for plugins that own a write (a project's create, a guarded configuration change). */
export const Access = local("p3.access", { version: 1, params: {}, members: { require: method(RequestData) } });
export function guard<C extends RemoteContract, M extends MethodName<C>>(
access: Ref<typeof Access>, actor: UserId, op: Operation<C, M>, target: ParamsOf<C>, args: ArgsOf<C, M>, baseline: Decision, ctx: Ctx,
): Promise<void>;
(_request, baseline) => baseline, its own plugin, installed explicitly. Baselines come
from the facts the asking code has: the owner rule, a grouping's mayBring, the session owner, the users plugin's
admin flag, allow for use unless the definition says otherwise.Access with a baseline from their own facts.MyResources.creatable is a derive over the registered definitions, the
policy value and the user's memberships. An admin adding research to the MCP rule commits the rules document, the
rules plugin sets its policy writable, the registry recomposes, the derive recomputes, Cristina's picker gains the
MCP creator; nothing polls.The worker gets its session's list; the harness keys extensions by resource and checks at compose time and at every call.
// plugins/resources/contract.ts
/** A session worker's view of its list: the worker's principal names the session. */
export const SessionResources = remote("p3.resources.session", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { worker: { state: { list: SessionListSchema } } },
});
// plugins/resources/server.ts
host.serve(SessionResources, (_params, principal, self) => {
if (principal.kind !== "worker") throw forbidden("session workers only");
return { variant: "worker", members: { list: self.get(SessionList, { session: principal.session }).list } };
});
The harness plugin owns the Extensions registry; contributions are pairs of a resource and the Durable extension
that needs it:
// plugins/harness/contract.worker.ts
export type HarnessExtension = readonly [resource: ResourceRef, extension: Extension];
export const Extensions = defineRegistry<
readonly HarnessExtension[] | Watchable<readonly HarnessExtension[]>, ExtensionTable
>("harness.extensions", { version: 1 });
// plugins/harness/worker.ts
host.serve(Extensions, { compose: (table, { report }) => byResource(table, report) }); // one extension per resource key
host.serve(HarnessHost, (_params, self) => {
const extensions = self.registry(Extensions);
const resources = self.get(SessionResources, {});
/** Whether `resource` may be used now: the server link live, the list ready, the entry there and not expired. */
const allowed = (resource: ResourceRef): boolean => {
if (resources.state.value.kind !== "live") return false; // a lost link keeps last values, not permission
const list = resources.list.value;
if (list.kind !== "ready") return false;
const now = Date.now();
return list.entries.some((e) => sameResource(e.resource, resource) && (e.until === null || e.until > now));
};
// Durable's registry, built as p2's durableRegistry (apps/p2/src/runtime/durable.ts) with `allowed` in place of
// switches: rebuilt when the extensions or the list change, and every tool's execute wrapped so a call is refused
// when `allowed` turns false mid-phase (captured code-mode tools, nested calls included).
const registry = self.open((ctx) => durableRegistry(extensions, resources.list, allowed, ctx), closeRegistry);
// ...Durable opens with `registry`
return {};
});
Doors that are not tools check the same list at their own entry: the composer's !cmd needs Bash on the list and a
location; a cache warmer re-checks its provider before every refresh; terminal and voice channels re-check on use.
A plugin that brings its tools: three lines on the server and the worker.
// plugins/coding-tools/contract.ts
export const FileTools = defineResource("coding-tools.files");
export const Bash = defineResource("coding-tools.bash");
// plugins/coding-tools/server.ts
export default defineFacet((host, self) => {
self.contribute(InstanceResources, [FileTools, Bash]);
});
// plugins/coding-tools/worker.ts
export default defineFacet((host, self) => {
self.contribute(Extensions, [
[FileTools, defineExtension({ name: "files", tools: [read, write, edit] })],
[Bash, defineExtension({ name: "bash", tools: [bash] })],
]);
});
coding-tools.files and coding-tools.bash exist in the resources plugin's index, owned by INSTANCE.A third-party plugin's tool works the same way and is in every defaults session's next turn. Splitting bash from the file tools is the plugin's choice: two resources, two extensions.
Resources people create, a store on the server, a per-session view, extensions that follow it, and the page.
// plugins/mcp/contract.ts
export const McpServer = defineResource("mcp.server");
export const Transport = union([
obj({ kind: lit("http"), url: str() }), // no location
obj({ kind: lit("server-stdio"), command: str(), args: arr(str()) }), // the p3 machine; admin by baseline
obj({ kind: lit("checkout"), env: ResourceRef, dir: str(), entry: str(), digest: str() }), // an enabled .pi/mcp.json entry
]);
export const McpConfig = obj({ name: str(), transport: Transport, lifetime: oneOf("session", "shared") });
/** A signed-in user's MCP: their servers, and the methods behind the creation sheet and the detail panels. */
export const McpServers = remote("mcp.servers", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
user: {
state: { servers: arr(ServerInfo) }, // id, name, transport (no secrets), status, tools, owner
methods: {
create: method(obj({ owner: str(), config: McpConfig }), ResourceRef),
configure: method(obj({ id: str(), config: McpConfig })),
signIn: method(obj({ id: str() }), SignInStart),
remove: method(obj({ id: str() })),
},
},
},
});
export const CreateMcp = operation(McpServers, "create");
export const ConfigureMcp = operation(McpServers, "configure");
/** A session worker's MCP: the servers on its list, and calls the server runs. */
export const SessionMcp = remote("mcp.session", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
worker: {
state: { servers: arr(SessionServer) }, // { resource, name, tools }
methods: { call: method(obj({ server: str(), conversation: int(), tool: str(), args: unknown() }), CallResult) },
},
},
});
// plugins/mcp/server.ts
const Configs = defineTable("mcp.configs", { version: 1, columns: { id: str(), owner: str(), config: json(McpConfig, { version: 1 }) }, key: ["id"] });
/** The MCP store (server only, host lifetime): configurations, secrets, the supervisor of clients and processes. */
const McpStore = local("mcp.store", {
version: 1,
params: {},
members: {
servers: map(ServerInfo), // by id; no secrets
add: method(obj({ id: str(), owner: str(), config: McpConfig })),
call: method(obj({ id: str(), session: str(), at: nullable(Location), tool: str(), args: unknown() }), CallResult),
},
});
export default defineFacet((host) => {
host.serve(McpStore, (_params, self) => {
const configs = self.table(Configs);
const supervisor = self.open((ctx) => new McpSupervisor(ctx), (s) => s.close()); // processes, OAuth, refresh
/* servers: a follow of the supervisor's status and tools; add: tx.put(configs, ...); call: supervisor.call */
});
host.serve(McpServers, (_params, principal, self) => {
if (principal.kind !== "user") throw forbidden("users only");
const resources = self.get(Resources, {});
const access = self.get(Access, {});
const store = self.get(McpStore, {});
return {
variant: "user",
members: {
servers: self.derive([store.servers], ([all]) => Object.values(all).filter((s) => visibleTo(s, principal.id))),
create: async ({ owner, config }, ctx) => {
// Running code on the server machine needs an admin by baseline; a company policy may say otherwise.
await guard(access, principal.id, CreateMcp, {}, { owner, config }, transportBaseline(config, principal), ctx);
const resource = await resources.create({ actor: principal.id, owner, definition: McpServer, label: config.name }, ctx);
await store.add({ id: resource.id!, owner, config }, ctx);
return resource;
},
configure: async ({ id, config }, ctx) => { /* owner only; guard(ConfigureMcp) with the new transport; update */ },
signIn: async ({ id }, ctx) => { /* owner only: start the OAuth flow; tokens stay in the store */ },
remove: async ({ id }, ctx) => { /* owner only: delete config, then resources.forget(...) */ },
},
};
});
host.serve(SessionMcp, (_params, principal, self) => {
if (principal.kind !== "worker") throw forbidden("session workers only");
const list = self.get(SessionList, { session: principal.session }).list;
const store = self.get(McpStore, {});
const locations = self.get(ConversationLocations, {}); // sessions plugin: where each conversation works
// The session's MCP servers: list entries of McpServer whose config the store has.
const servers = self.derive([list, store.servers], ([current, all]) =>
current.kind !== "ready" ? [] : current.entries.flatMap(({ resource }) =>
isOf(resource, McpServer) && all[resource.id!] !== undefined
? [{ resource, name: all[resource.id!].name, tools: all[resource.id!].tools }] : []));
return {
variant: "worker",
members: {
servers,
call: async ({ server, conversation, tool, args }, ctx) => {
// Checked here at every call: on the list now, not expired; a checkout server only where this conversation works.
const entry = onList(list.value, resourceOf(McpServer, server));
if (entry === undefined) throw forbidden(messages.message("notOnList"));
const at = locations.of(principal.session, conversation);
return store.call({ id: server, session: principal.session, at, tool, args }, ctx);
},
},
};
});
});
// plugins/mcp/worker.ts
export default defineFacet((host) => {
host.serve(McpInSession, (_params, self) => {
const mcp = self.get(SessionMcp, {});
const extensions = self.writable<readonly HarnessExtension[]>([]);
self.contribute(Extensions, extensions);
// The server pushes `servers` again whenever the session's list or a server's tools change.
self.on(mcp.servers, ({ value }) => extensions.set(value.map((server) => [
server.resource,
mcpExtension(server, (tool, args, api, ctx) =>
mcp.call({ server: server.resource.id!, conversation: api.conversationId, tool, args }, ctx)),
] as const)));
return {};
});
});
MyResources.withdraw raises Linear's revision in the index and asks the projects plugin to remove the entry.SessionList for every pi session recomputes without Linear; SessionMcp.servers recomputes and is pushed.self.on replaces the extensions; the harness also stops admitting Linear's.SessionMcp.call and is refused there. Output already in the transcript stays.Processes stay per session by default (lifetime: "session"), as in p2; shared keeps one per resource and
placement. Credentials and OAuth refresh never leave the store.
A bot the company owns, a channel that is a grouping with a list, and access to one session for an hour.
Three plugins meet here: discord (the bot, the channel grouping, ingress), conversations (the tool and access to another session), and the resources plugin (lists).
// plugins/discord/contract.ts
export const DiscordBot = defineResource("discord.bot");
export const DiscordChannel = defineGrouping("discord.channel"); // id: "<bot id>:<channel id>"
export const DiscordAdmin = remote("discord.admin", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
user: {
state: { bots: arr(BotInfo), links: arr(LinkInfo) },
methods: {
createBot: method(obj({ owner: str(), token: str(), name: str() }), ResourceRef),
link: method(obj({ bot: str(), channel: str(), session: str() })),
unlink: method(obj({ bot: str(), channel: str() })),
},
},
},
});
createBot calls resources.create({ actor, owner: INSTANCE, definition: DiscordBot, ... }): only an admin passes
the owner rule. The token stays in the discord plugin's server store.
// plugins/discord/server.ts (inside the discord store, host lifetime)
self.contribute(Groupings, [{
grouping: DiscordChannel,
resources: {
lists: channelLists, // keyed by channel id, persisted
mayBring: (actor, id) => linkOf(id)?.by === actor || isAdmin(actor),
mayRemove: (actor, id) => linkOf(id)?.by === actor || isAdmin(actor),
mayJoin: (actor, id) => isAdmin(actor) || linkOf(id)?.by === actor,
add: (id, entry, ctx) => channelLists.change(id, (d) => { d.push(entry); }, ctx),
remove: (id, resource, ctx) => channelLists.change(id, (d) => removeEntry(d, resource), ctx),
},
}]);
Linking a channel to session S2 (DiscordAdmin.link), in order:
conversations.tool; nothing else.{ kind: "grouping", grouping: groupingOf(DiscordChannel, id) }
(SessionCatalog.setSource, with the session side and the channel's mayJoin checked) and S2's location to null:
no machine.Unlinking stops ingress first, then restores the previous source if the actor may still set it, else leaves S2 an own list of the authorized entries.
// plugins/conversations/contract.ts
export const ConversationsTool = defineResource("conversations.tool"); // brought: the tool, reaching same-source sessions
export const ConversationAccess = defineResource("conversations.access"); // created: access to one session, chosen actions
export const AccessConfig = obj({ target: str(), actions: arr(oneOf("answer", "send")) });
export const Accesses = remote("conversations.accesses", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: { user: { state: { accesses: arr(AccessInfo) }, methods: { create: method(obj({ config: AccessConfig }), ResourceRef) } } },
});
export const SessionConversations = remote("conversations.session", {
version: 1,
params: {},
hostAddress: () => Server(),
variants: {
worker: {
state: { targets: arr(Target) }, // { session, title, actions }: what the tool lists
methods: {
send: method(obj({ to: str(), message: str(), requestId: str() })),
answer: method(obj({ to: str() }), AnswerResult),
},
},
},
});
// plugins/conversations/server.ts
self.contribute(InstanceResources, [ConversationsTool]);
host.serve(Accesses, (_params, principal, self) => {
/* create: baseline "the actor may send in the target" (the sessions plugin's maySend); guard; then
resources.create({ actor, owner: actor, definition: ConversationAccess, label: `${title} · answers` });
store.add({ id, config }) */
});
host.serve(SessionConversations, (_params, principal, self) => {
const list = self.get(SessionList, { session: principal.session }).list;
const store = self.get(AccessStore, {}); // server only: configs by resource id
const catalog = self.get(SessionCatalog, {});
const targets = self.derive([list, store.configs, catalog.visible], targetsOf); // display only
/** The entry behind one call: the tool for a same-source target, else an access naming target and action, live. */
const authorize = (to: string, action: "answer" | "send") => {
const current = list.value;
if (current.kind !== "ready") throw forbidden(messages.message("listUnavailable"));
const now = Date.now();
const live = current.entries.filter((e) => e.until === null || e.until > now);
if (live.some((e) => isOf(e.resource, ConversationsTool)) && sameSource(catalog.row(principal.session), catalog.row(to))) return;
const grant = live.find((e) => isOf(e.resource, ConversationAccess) &&
store.config(e.resource.id!)?.target === to && store.config(e.resource.id!)!.actions.includes(action));
if (grant === undefined) throw forbidden(messages.message("noAccess", action));
requireStillAllowed(grant.owner, to, action); // the access owner may still send in the target
};
return {
variant: "worker",
members: {
targets,
send: async ({ to, message, requestId }, ctx) => { authorize(to, "send"); return broker.send({ from: principal.session, to, message, requestId }, ctx); },
answer: async ({ to }, ctx) => { authorize(to, "answer"); return broker.answer({ to }, ctx); },
},
};
});
/** Same source: defaults sessions of the same owner, sessions of the same grouping; an own list peers with nobody. */
function sameSource(a: SessionRow, b: SessionRow): boolean {
if (a.id === b.id) return false;
switch (a.source.kind) {
case "defaults": return b.source.kind === "defaults" && a.owner === b.owner;
case "grouping": return b.source.kind === "grouping" && sameGrouping(a.source.grouping, b.source.grouping);
case "own": return false;
}
}
// plugins/conversations/worker.ts: one extension, keyed by the tool; its targets come from the server view
self.contribute(Extensions, [[ConversationsTool, conversationsExtension(session)]]);
answer), how
long (one hour). It calls Accesses.create: the baseline "Mario may send in the target" passes; core creates the
resource owned by Mario.until = now + 1 h: the resource side passes (Mario owns it),
the list side passes (Mario made the link).SessionList gains the entry; SessionConversations.targets lists one session with answer; the tool shows
one target.send is refused by authorize; Lena's question is
answered from the release session's latest answer.authorize against the current
time, whatever the worker still shows.S2's list never holds bash, a machine or an MCP server, so none of them exist in that session.
Creation sheets, rows and detail panels per resource definition; the session panel, the picker and My resources are core's; every action is a server method.
The page never decides anything. It draws server views and calls their methods; the server checks. What a plugin adds is UI for its own resources, through three page registries of the resources plugin:
// plugins/resources/contract.web.ts
/** The creation sheet of one definition: offered by every add-resource picker and by My resources. */
export type Creator = {
readonly definition: ResourceDefinition;
readonly label: MessageData;
readonly description: MessageData;
readonly icon: Icon;
/** `owner`: the user, or INSTANCE when an admin creates a company resource. `done` gets the new resource. */
readonly Sheet: ComponentType<{ readonly owner: UserId; done(resource: ResourceRef): void; cancel(): void }>;
};
export const Creators = defineRegistry<readonly Creator[], CreatorIndex>("p3.creators", { version: 1 });
/** How one definition's resources look as a row (session panel, picker, project, My resources). */
export type ResourceRowRenderer = { readonly definition: ResourceDefinition; readonly Row: ComponentType<{ readonly resource: ResourceRef }> };
export const ResourceRows = defineRegistry<readonly ResourceRowRenderer[], RowIndex>("p3.resource-rows", { version: 1 });
/** A part of one resource's detail sheet; several plugins may contribute for one definition. */
export type ResourcePanel = {
readonly definition: ResourceDefinition;
readonly title: MessageData;
readonly order: number;
readonly Panel: ComponentType<{ readonly resource: ResourceRef }>;
};
export const ResourcePanels = defineRegistry<readonly ResourcePanel[], PanelIndex>("p3.resource-panels", { version: 1 });
And two server views core serves for the page, next to MyResources:
// plugins/resources/contract.ts
/** One resource as a signed-in user sees it: what every detail sheet's header shows. */
export const ResourceInfo = remote("p3.resources.info", {
version: 1,
params: { name: str(), id: str() }, // id "" for a brought resource
hostAddress: () => Server(),
variants: {
user: {
state: { info: nullable(obj({ label: str(), owner: str(), lists: arr(ListUse), actions: InfoActions })) },
methods: {}, // withdraw, exclude, remove: MyResources' methods
},
},
});
/** A session's list as a user who may see the session sees it: what the session's resource panel shows. */
export const SessionResourcesView = remote("p3.resources.session-view", {
version: 1,
params: { session: str() },
hostAddress: () => Server(),
variants: {
user: { state: { view: nullable(obj({ source: ResourceSource, list: SessionListSchema, actions: PanelActions })) } },
},
});
ListUse is one list the resource is on that this user may see, with its expiry; InfoActions and PanelActions
say what this user may do now (withdraw, exclude, add, remove, lock down, change source), computed with the same rules
and policy the methods check, so buttons match what the server will accept.
// plugins/resources/web.tsx (core)
/** One resource as a row: its definition's renderer, else the label from the index. */
export function ResourceRowView({ resource }: { resource: ResourceRef }) {
const rows = useRegistry(ResourceRows);
const Row = rows.get(resource.name);
return Row ? <Row resource={resource} /> : <PlainRow resource={resource} />; // PlainRow holds ResourceInfo for the label
}
/** A resource's detail sheet: core's header, then every panel contributed for its definition, in order. */
export function ResourceDetail({ resource }: { resource: ResourceRef }) {
const info = useWatch(useHold(ResourceInfo, { name: resource.name, id: resource.id ?? "" }).info);
const panels = useRegistry(ResourcePanels).filter((p) => p.definition.name === resource.name);
if (info === null || info === undefined) return <Unavailable />;
return (
<Sheet title={info.label}>
<OwnerLine owner={info.owner} />
<UsedIn lists={info.lists} /> {/* "pi · until Fri", "Support bot" */}
<HeaderActions resource={resource} actions={info.actions} /> {/* Withdraw everywhere, Exclude from my defaults */}
<Tabs>{panels.map((p) => <Tab key={p.title.key} title={p.title}><p.Panel resource={resource} /></Tab>)}</Tabs>
</Sheet>
);
}
/** The session's resource panel: where its list comes from, the entries, and what this user may do. */
export function SessionResourcesPanel({ session }: { session: string }) {
const view = useWatch(useHold(SessionResourcesView, { session }).view);
if (view === null || view === undefined) return null;
return (
<Panel>
<SourceChip source={view.source} /> {/* "from project pi", "from #support", "your defaults" */}
{view.list.kind === "ready"
? view.list.entries.map((e) => <EntryRow key={keyOf(e.resource)} entry={e} actions={view.actions} />)
: <Unavailable reason={view.list.reason} />}
{view.actions.add && <AddResourceButton into={listOf(view.source, session)} />}
{view.actions.lockDown && <LockDownButton session={session} />}
</Panel>
);
}
/** "Add resource": things I may bring here, then the creators I may use; creating brings the new resource in. */
export function AddResource({ into }: { into: ListRef }) {
const mine = useHold(MyResources, {});
const creatable = useWatch(mine.creatable) ?? [];
const [owner, setOwner] = useState<UserId>(me); // admins also see "the company"
const allowed = creatable.find((c) => c.owner === owner)?.definitions ?? [];
const creators = useRegistry(Creators).filter((c) => allowed.includes(c.definition.name));
const candidates = useCall(() => mine.candidates({ into }), [into]);
const [until, setUntil] = useState<number | null>(null); // the picker owns the duration
const [open, setOpen] = useState<Creator>();
if (open) return <open.Sheet owner={owner} cancel={() => setOpen(undefined)}
done={(resource) => mine.bring({ resource, into, until })} />;
return <Picker candidates={candidates} creators={creators} until={until} onUntil={setUntil}
onBring={(resource) => mine.bring({ resource, into, until })} onCreate={setOpen} owner={owner} onOwner={setOwner} />;
}
My resources is a page of the same pieces: MyResources.owned as rows (ResourceRowView), each opening
ResourceDetail, and the creators the user may use. Nothing in it knows MCP, models or machines.
// plugins/mcp/web.tsx
export default defineFacet((host, self) => {
self.contribute(Creators, [{
definition: McpServer,
label: t.message("mcpServer"),
description: t.message("mcpServerAbout"), // "HTTP, or a command on this machine (admins)"
icon: PlugIcon,
Sheet: NewMcpSheet,
}]);
self.contribute(ResourceRows, [{ definition: McpServer, Row: McpRow }]);
self.contribute(ResourcePanels, [
{ definition: McpServer, title: t.message("tools"), order: 10, Panel: McpTools },
{ definition: McpServer, title: t.message("connection"), order: 20, Panel: McpConnection }, // status, sign in, reconnect
{ definition: McpServer, title: t.message("configuration"), order: 90, Panel: McpConfiguration },
]);
});
/** The creation sheet: a form for McpConfig; the server's create checks and makes the resource. */
function NewMcpSheet({ owner, done, cancel }: { owner: UserId; done(resource: ResourceRef): void; cancel(): void }) {
const servers = useHold(McpServers, {});
const [config, setConfig] = usePageState<McpConfig>("mcp.new", { name: "", transport: { kind: "http", url: "" }, lifetime: "session" });
const [error, setError] = useState<string>();
return (
<Form title={t("newMcpServer")} onCancel={cancel} onSubmit={async (ctx) => {
try { done(await servers.create({ owner, config }, ctx)); } catch (e) { setError(messageOf(e)); } // e.g. "only engineering ..."
}}>
<TextField label={t("name")} value={config.name} onChange={(name) => setConfig({ ...config, name })} />
<TransportField value={config.transport} onChange={(transport) => setConfig({ ...config, transport })} />
{error && <ErrorLine text={error} />}
</Form>
);
}
/** The row: name, status, tool count, from the plugin's own user view. */
function McpRow({ resource }: { resource: ResourceRef }) {
const servers = useWatch(useHold(McpServers, {}).servers) ?? [];
const server = servers.find((s) => s.id === resource.id);
return server ? <Row icon={PlugIcon} title={server.name} detail={`${server.tools.length} tools · ${server.status}`} /> : <PlainRow resource={resource} />;
}
A project member who is not the owner sees Colin's server in pi with the MCP plugin's row and panels; the panels read
McpServers, whose view for that member shows the server (it is on a list they see) without secrets and without the
owner's methods' effect: configure, signIn and remove refuse anyone but the owner, and the panel shows them only
when actions says so.
A plugin may contribute rows and panels for a definition it does not own, from the owner plugin's public contract: a
team plugin adds a "Usage" panel to McpServer reading its own usage store. Registering a panel grants nothing; the
data it shows is whatever its own views allow.
Three more definitions, each the same pattern: a row, a store keyed by id, a per-session view.
models.provider, created): the store keeps pi-ai credentials and refreshes on the server;
SessionModels pushes, per provider on the list, its models and pi-ai AuthResult with expiry; the worker's
provider auth reads the latest push and refuses when the list is not ready, the provider left it or the auth
expired. Logouts and edits push at once. Bedrock and Vertex with ambient credentials get a server-side stream or are
unsupported.envs.machine, created; envs.local-machine, brought): a conversation's location is null or
{ machine: ResourceRef, cwd } and must name a machine on the list; null means no shell, never a fallback to the
server machine. envs.local-machine declares a use baseline: the session's owner administers. The machine's detail
sheet gets panels from the files, terminals and envs plugins.drives.drive, created): kit Files per drive; the owner manages it, members of a list holding it read;
one extension keyed per drive on the list.SAML and Radius touch three places, each already in the model.
Identities.signIn({ issuer, subject, profile }) from a verified assertion; never email
(chapter II).SamlGroup registers only members, updated at sign-in or by SCIM; a stale
directory yields no members, never everyone.Signing in with Radius can create the user's Radius MCP server and model provider through those plugins' trusted local creates, with the user the sign-in produced as actor. Whether one Radius sign-in serves relay, gateway and MCP is open with Armin and Vegard.
The cases every change must keep working.
Four decisions for the owner.
On other tracks: execution on the server machine and SSH keys in workers; ambient cloud credentials; one Radius sign-in.
| File | Holds |
|---|---|
plugins/resources/contract.ts |
defineResource, ResourceRef, isOf, resourceOf, defineGrouping, GroupingRef, ResourceSource, ListEntry, ListRef, SessionListSchema, MyResources, ResourceInfo, SessionResourcesView, SessionResources |
plugins/resources/contract.server.ts |
InstanceResources, Resources, SessionList, Groupings, Members |
plugins/resources/contract.web.ts |
Creators, ResourceRows, ResourcePanels |
plugins/resources/server.ts |
the index, create, own lists, SessionList, the user and worker views |
plugins/resources/web.tsx |
ResourceRowView, ResourceDetail, SessionResourcesPanel, AddResource, My resources |
plugins/users/contract.ts, contract.server.ts |
UserId, INSTANCE, P3Principal, Profile, Me, People, UserAdmin; users (the kit's defineUsers), Identities |
plugins/policy/contract.server.ts |
Request, Decision, Policy, Policies, Access, guard |
plugins/harness/contract.worker.ts |
HarnessExtension, Extensions |
plugins/sessions/contract.server.ts |
SessionRow (with source), SessionCatalog |
a plugin's contract.ts |
its defineResource / defineGrouping constants and the views its facets share |
a plugin's server.ts |
its store (server only), InstanceResources / Groupings contributions, its views |
a plugin's worker.ts |
its Extensions contributions, following its session view |
a plugin's web.tsx |
its Creators, ResourceRows, ResourcePanels |
The third edition follows the owner's code review: no resource objects, no resource kinds, no built-ins, no grants,
no plugin-name check (installed plugins are trusted code); a resource is a row named by defineResource; brought
resources are registry contributions, created ones Resources.create; extensions are keyed by their resource;
server-only declarations live in contract.server.ts, never in a plugin's shared contract.ts. The fixes from Astra's
and Sol's review of the second edition carry over: withdrawal by revision, lock-down and copies re-checked as brings,
admin baseline for company resources, per-call admission, doors that are not tools, null locations, per-action
expiry for conversation access, the calling conversation on MCP calls, revision-captured model pushes, typed session
operations, actor as data on local calls, the default policy as its own plugin.
p3 resource model · spec, third edition · 2026-11-08