From 13d1484f0d947cd543e26023581a86d4ec3dad9e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 20 May 2026 16:38:02 +0400 Subject: [PATCH 1/3] feat: RFC for the users capsule (astrid:users@1.0.0) Extracts the cross-platform user identity directory out of the kernel into a first-party capsule that publishes the astrid:users@1.0.0 IPC interface. Defines the topic surface (users.v1..{request,response}), the source envelope (channel, user-id, correlation-id) for multi-tenant uplink routing, the KV storage layout (compatible with the legacy astrid-storage::identity store), and the kernel-side cutover plan. Tracks unicity-astrid/astrid#747. WIT lives in unicity-astrid/wit#feat/users-interface. Capsule scaffold lives in unicity-astrid/capsule-users#feat/initial-scaffold. SDK bindings shipped in unicity-astrid/sdk-rust#feat/users-interface and unicity-astrid/sdk-js#feat/users-interface. --- text/0000-users-capsule.md | 374 +++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 text/0000-users-capsule.md diff --git a/text/0000-users-capsule.md b/text/0000-users-capsule.md new file mode 100644 index 0000000..f2fb0b5 --- /dev/null +++ b/text/0000-users-capsule.md @@ -0,0 +1,374 @@ +- Feature Name: `users_capsule` +- Start Date: 2026-05-20 +- RFC PR: [rfcs#0000](https://github.com/unicity-astrid/rfcs/pull/0000) +- Tracking Issue: [astrid#747](https://github.com/unicity-astrid/astrid/issues/747) + +# Summary +[summary]: #summary + +Extract the cross-platform user identity directory out of the kernel into a +first-party capsule (`astrid-capsule-users`) that publishes the +`astrid:users@1.0.0` IPC interface. The kernel keeps principal-level tenancy; +the capsule owns within-principal user attribution — `AstridUserId`, +`FrontendLink`, and the resolve / link / unlink / create / list surface. + +This RFC defines the IPC topic schema (`users.v1.*`), the request/response +envelope (correlation IDs for multi-tenant uplinks), the KV storage layout the +capsule must use, and the cutover plan that strips the legacy identity surface +out of `astrid-core`, `astrid-storage`, the WASM host fn module, and the +manifest capability declaration. + +# Motivation +[motivation]: #motivation + +The kernel rule is "the kernel routes events and enforces capabilities; +everything else is capsule-space business logic." Today the kernel violates +that rule with the identity subsystem: + +- `astrid-core::identity::types` owns `AstridUserId` and `FrontendLink`. +- `astrid-storage::identity` owns the `IdentityStore` trait and a KV-backed + implementation with eight async methods. +- `astrid-capsule::engine::wasm::host::identity` exposes five `identity_*` + host functions to WASM guests. +- `astrid-capsule::manifest::capabilities` declares the `identity = [...]` + capability with a three-tier hierarchy (`resolve` / `link` / `admin`). + +Every new uplink that wants to attribute messages to a user — sphere-capsule +today, Discord/Telegram/web-passkey tomorrow — touches kernel-side types. +That's domain logic in the kernel. + +The capsule design also unlocks adjacent work: + +- Uplinks can register new platforms (Nostr, browser extensions, third-party + webhooks) without kernel changes. +- Identity-as-domain growth (admin/role markers, denylists, identity + recovery) lives in capsule-space where it belongs. +- The naming collision with `astrid-capsule-identity` (the *agent's* persona) + goes away. + +The cost of the move is small because there are no external consumers +pre-launch: a clean cutover replaces the in-process host fn with IPC RPC. + +# Guide-level explanation +[guide-level-explanation]: #guide-level-explanation + +## What is the users capsule + +`astrid-capsule-users` is a first-party capsule that maintains a directory of +canonical `AstridUserId`s and the platform identities linked to them. Every +Astrid principal has its own independent store: different principals never +share user records. + +Within a principal, the store answers questions like: + +- "Discord user `12345` just messaged me. Who is that?" — `resolve`. +- "Pair Telegram chat `tg:abc` with AstridUser `0000…0001`." — `link`. +- "List every platform Alice is reachable on." — `links`. + +The capsule's entire public surface is IPC pub/sub. There is no host function, +no shared in-process state, and no special privilege beyond the per-capsule +KV scope every capsule already has. + +## How a multi-tenant uplink talks to it + +A multi-tenant uplink (one Astrid principal serving N humans — sphere-capsule, +future Discord-capsule) routes responses back to the originating end-user +using correlation IDs. Each request carries a `source` envelope: + +```json +{ + "channel": "discord", + "user-id": "00000000-0000-4000-8000-deadbeefdead", + "correlation-id": "9d8a7f3e-..." +} +``` + +* `channel` — free-form uplink identifier, recorded for audit. +* `user-id` — the requester's own `AstridUserId` when known. `None` for + pre-login flows. +* `correlation-id` — the token the uplink filters the response topic by. + +The uplink: + +1. Generates a fresh `correlation-id` (UUID is the obvious choice). +2. Publishes `users.v1.resolve.request` with that correlation. +3. Subscribes to `users.v1.resolve.response`. +4. Drops every response whose `correlation-id` does not match. +5. Routes the matched response back to the originating end-user. + +The same correlation pattern works across every operation. The response +topic is **fixed** — no per-correlation suffix — so the uplink only needs +one subscription per operation type for its entire lifetime. + +## What lives in the kernel still + +- `PrincipalId` (the tenancy primitive). +- Per-principal KV scoping (the capsule's storage substrate). +- Topic ACL enforcement (the capsule declares `[publish]` and `[subscribe]` + in its manifest; the kernel forbids any other capsule from publishing the + `users.v1.*.response` topics). + +# Reference-level explanation +[reference-level-explanation]: #reference-level-explanation + +## IPC topic surface + +| Request topic | Response topic | Operation | +|---|---|---| +| `users.v1.resolve.request` | `users.v1.resolve.response` | `(platform, platform-user-id) → option` | +| `users.v1.link.request` | `users.v1.link.response` | Upsert a platform link to an existing user | +| `users.v1.unlink.request` | `users.v1.unlink.response` | Remove a platform link | +| `users.v1.create.request` | `users.v1.create.response` | Create a new `AstridUser` (optionally with display name) | +| `users.v1.links.request` | `users.v1.links.response` | List every link for one user | +| `users.v1.get.request` | `users.v1.get.response` | Fetch a user by UUID | +| `users.v1.delete.request` | `users.v1.delete.response` | Delete a user and cascade every link pointing at it | +| `users.v1.list.request` | `users.v1.list.response` | List every user record in the principal's store | + +Each request carries a `source` envelope (above). Each response echoes the +`correlation-id` plus operation-specific success fields and an optional +`error` string. + +## WIT schema + +Defined in [`unicity-astrid/wit/interfaces/users.wit`](https://github.com/unicity-astrid/wit/blob/main/interfaces/users.wit) as the +`astrid:users@1.0.0` package. Records: + +- `source` — request envelope (channel, user-id, correlation-id). +- `astrid-user` — `{ id, display-name?, public-key?, created-at }`. +- `frontend-link` — `{ platform, platform-user-id, astrid-user-id, linked-at, method }`. +- `{op}-request` / `{op}-response` for every operation above. + +The bundle is mirrored into both SDKs via `scripts/sync-contracts-wit.sh`, +emitted as `astrid_sdk::contracts::users::*` in Rust and +`import { users } from '@astrid-os/sdk/contracts'` in TypeScript. + +## Source envelope vs. issue #748 + +The `source` envelope intentionally mirrors the multi-tenant correlation +convention that admin-API issue [astrid#748](https://github.com/unicity-astrid/astrid/issues/748) +will eventually formalize for `cli.v1.command.execute`. Two things to note: + +1. **This RFC does not depend on #748 landing first.** Defining `source` + inside `astrid:users@1.0.0` was the cheapest path to ship; the convention + is local to this interface until #748 hoists it. +2. **When #748 lands, the convention hoists.** A future shared envelope + package (working name `astrid:envelope@1.0.0`) replaces the in-interface + record and `users.wit` rewrites its imports. The wire shape stays + compatible — kebab-case field names already match #748's draft. + +## Storage layout + +The capsule's per-principal KV scope holds three key families. Layout is +identical to the legacy `astrid-storage::identity` store so the cutover +reads existing records without migration: + +| Key | Value | Purpose | +|---|---|---| +| `user/{uuid}` | JSON `AstridUser` | Canonical user record. | +| `link/{platform}/{platform_user_id}` | JSON `FrontendLink` | Platform link, composite key. | +| `name/{display_name}` | UTF-8 UUID string | Best-effort display-name lookup index (last writer wins). | + +`platform` and `platform_user_id` MUST be validated to reject `/` and `\0` +before key construction. Without that gate, a caller passing +`platform = "../user"` could collide with `user/{uuid}` records through the +link path. The validation is part of the contract, not an implementation +detail — every consumer that re-implements this store (e.g. a future kernel +fallback or a non-Rust port) must enforce it identically. + +## Error semantics + +Each response has an optional `error: string` field. A clean "not found" +result (`resolve` of an unlinked platform identity, `get` of a missing UUID) +returns `error = none` with the success field set to its empty form (`user += none`, `users = []`, etc.). `error` is populated only when the operation +itself failed — input validation, storage errors, or a `UserNotFound` +returned by `link` when the target UUID does not exist. + +## Capsule manifest declarations + +```toml +[exports] +"astrid:users" = "1.0.0" + +[publish] +"users.v1.resolve.response" = { wit = "@unicity-astrid/wit/users/resolve-response" } +# … one entry per operation, eight total. + +[subscribe] +"users.v1.resolve.request" = { wit = "@unicity-astrid/wit/users/resolve-request", handler = "handle_resolve" } +# … one entry per operation, eight total. +``` + +No host capabilities are declared. The capsule needs only its per-capsule +KV scope, granted by default. + +## Migration / cutover plan + +The kernel-side identity surface is removed in a separate PR once the SDK +wrappers cut over: + +1. **Land within-principal correlation convention** ([astrid#748](https://github.com/unicity-astrid/astrid/issues/748)) + — *not a hard dependency.* This RFC ships its own envelope. Hoist later. +2. **Build `astrid-capsule-users`** — this RFC. +3. **Rewrite SDK wrappers.** `astrid_sdk::identity::*` and + `@astrid-os/sdk` identity helpers stop calling the host fn and start + publishing IPC RPCs (subscribe-and-poll with timeout). Consumer code + keeps the same external API. +4. **Strip kernel-side identity.** Delete: + - `astrid-core::identity::{types, mod}` (modules and re-exports). + - `astrid-storage::identity` (trait + KV impl). + - `astrid-capsule::engine::wasm::host::identity` (host fn module + 5 fns). + - `IdentityResolveRequest`/`IdentityLinkRequest`/… records from + `contracts/host/astrid-capsule.wit` (kernel host ABI). + - `CapabilitiesDef::identity` and the + `identity_capability_satisfies` hierarchy from + `astrid-capsule::manifest::capabilities`. + - The `[capabilities].identity = ["…"]` declarations on every existing + capsule (replaced with `[publish]`/`[subscribe]` entries on the + `users.v1.*` topics). + +Pre-launch, no external consumers. No deprecation window. + +# Drawbacks +[drawbacks]: #drawbacks + +- **Latency.** Identity calls become async IPC RPCs instead of in-process + host fns. The roundtrip cost is one bus hop plus serialization — small, + but non-zero. The SDK wrappers stay sync via subscribe-and-poll-with- + timeout, which adds a per-call subscription churn (or a long-lived + subscription with internal request dispatch). +- **Two-way contract change.** Splitting a host fn into an IPC RPC pair is + a bigger change than tweaking an existing topic. Every uplink that + resolves user IDs has to migrate. Mitigated by pre-launch timing — no + external consumers exist. +- **Per-principal isolation.** Two principals on the same kernel cannot + share user records. The kernel's existing tenancy model already enforces + this, but the move makes it explicit: there is no "principal-zero" or + cross-principal admin path. If a federation / cross-principal flow is + ever needed, it has to be designed top-down. + +# Rationale and alternatives +[rationale-and-alternatives]: #rationale-and-alternatives + +## Why a capsule, not "fix the kernel module" + +Identity-as-domain *will* grow: admin/role markers, denylists, identity +recovery flows. Every new identity feature added to the kernel module +inflates the kernel surface for capsule authors who don't use it. Putting +the surface in capsule-space puts evolution behind the same versioned +interface contract every other capsule uses. + +## Why fixed response topics + correlation IDs + +Two designs were considered for the response routing: + +1. **Per-request response topic.** Request payload carries + `response_topic = "users.v1.resolve.response."`; capsule + publishes there. Used by `prompt_builder.v1.hook.before_build`. +2. **Fixed response topic + correlation ID** (chosen). Capsule always + publishes to `users.v1.resolve.response`; requester filters by + `correlation-id`. + +The fixed-topic design is cheaper for the requester: one long-lived +subscription per operation type instead of one short-lived subscription per +inflight request. For high-rate operations (`resolve` on every incoming +message), the difference adds up. Per-request topics also create a fan-out +problem in topic ACL evaluation — the kernel walks an ACL set every publish, +and a per-correlation-ID topic name churns the ACL cache. + +The downside is that every subscriber sees every response. Within a +principal, this is fine — same-principal capsules can already see each +other's traffic, and the correlation-ID filter is trivially cheap. Across +principals, the per-principal KV scope keeps stores isolated regardless. + +## Why include `get` / `delete` / `list` beyond the issue's stated five + +The kernel's `IdentityStore` trait already has `get_user`, `get_user_by_name`, +`delete_user`, and `list_users`. The kernel uses `delete_user` from the +admin handlers (`kernel_router::admin::handlers`). Without these in the +capsule surface, the cutover would leave the admin handlers in a partial +state, OR the SDK would need a fallback path. Including them now keeps the +migration mechanical. + +`get_user_by_name` is the lone holdout — it's an internal kernel call site, +not part of the SDK surface, and the name index is best-effort +last-writer-wins. The capsule keeps the underlying KV entry but doesn't +expose a topic for it. Add later if a use case appears. + +## Why a `source` envelope on every request, not just where uplinks need it + +Three options: + +1. Optional `source` on every request, fill in only where needed. +2. Required `source` on every request, with sentinel values + (`channel = "system"`, `correlation-id = "—"`). +3. Two parallel topic namespaces (single-tenant vs. multi-tenant). + +(3) doubles the surface — not worth it. (1) makes the protocol depend on +which operations happen to have multi-tenant callers today, which is the +brittle option. (2) is simple and forces every caller to think about +attribution. + +# Prior art +[prior-art]: #prior-art + +- **Matrix's identity service** — a separate service from the homeserver, + maps third-party identifiers (email, phone) to Matrix user IDs. Same + architectural decision: identity-as-domain is its own service, not part + of the routing kernel. +- **OAuth resource servers** — opaque identifier mapping is owned by a + resource server, not the authorization server. Astrid's split mirrors + this: principal tenancy (kernel) is distinct from within-principal + identity directory (capsule). +- **`urn:` URN registries** — platform-namespaced identifier mapping is a + well-trodden pattern; this RFC reinvents none of that, just declares + `platform` as the namespace and `platform_user_id` as the opaque value. + +# Unresolved questions +[unresolved-questions]: #unresolved-questions + +- **Auto-create on first resolve.** Should the capsule expose a + `resolve-or-create` topic that handles "I see Discord user `12345`, + give me an `AstridUser` (creating one if needed)" in one round trip? + Today the caller does `resolve` then `create` then `link` — three + hops. Likely yes; punt to a follow-up RFC once a concrete consumer + needs it. +- **Role markers / ACLs.** The issue floats a `principal_relation` enum + (`admin` / `peer` / `unknown`) on `FrontendLink`. This RFC does not + include it. The intent is to add a separate `astrid:authz@1.0.0` + interface that consumes `users.v1.*` outputs, rather than baking + policy into the identity record itself. Open question whether that + separate interface lives in this capsule or another. +- **SDK sync vs async.** The kernel-side host fn is synchronous from + the caller's perspective. IPC RPC is inherently async (publish, + await response). The SDK can: + 1. Keep sync semantics — internal subscribe + poll loop with timeout. + 2. Expose async fns — cleaner, matches reality. + Recommend (2) for greenfield code; expose a (1) facade for the + cutover so existing call sites don't all rewrite at once. +- **#748 envelope hoisting.** If #748's final envelope shape diverges + from `users.wit`'s, the hoist becomes a breaking change against + `astrid:users@1.0.0`. Mitigated by keeping the field names identical + in both drafts; revisit at #748 merge. + +# Future possibilities +[future-possibilities]: #future-possibilities + +- **Federation / cross-principal lookup.** A separate capsule could + publish federation requests across principals (with appropriate + cross-principal capability gating). The users capsule would not change + — federation is a layer above identity. +- **Webhook-driven link verification.** A future `users.v1.verify.request` + could carry a cryptographic challenge the platform-side endpoint signs. + This RFC explicitly does not include verification — `method` is just a + free-form audit string today. +- **Capsule-to-capsule queries with explicit grants.** Once the + authorization story (above) lands, an admin capsule could be granted + read-only access to `users.v1.list.*` for reporting, while consumer + capsules only see `resolve`. +- **Audit log topic.** Every mutation (`link`, `unlink`, `create`, + `delete`) could fan out to `users.v1.audit.event` so an audit capsule + observes the full mutation stream without polling. Out of scope here. +- **Public-key authentication.** `AstridUser.public_key` is reserved in + the type but unused by this RFC. A future RFC defines the verification + flow (e.g. ed25519 signed `users.v1.verify.request` payloads). From bad1bf060eb5a6964a7e2f0dc81f742bb5042c18 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 20 May 2026 17:33:38 +0400 Subject: [PATCH 2/3] =?UTF-8?q?rfc:=20clarify=20storage-layout=20claim=20?= =?UTF-8?q?=E2=80=94=20key=20scheme=20is=20byte-for-byte;=20value=20shapes?= =?UTF-8?q?=20follow=20WIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier 'identical to the legacy store' phrasing was overstated. Three deliberate value-shape divergences (public_key list vs base64, ms vs us timestamp precision, dropped principal field) come from following the WIT contract instead of the kernel's chrono+serde defaults. Pre-launch the divergence is academic; spell it out so the cutover migration tool knows what to translate. --- text/0000-users-capsule.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/text/0000-users-capsule.md b/text/0000-users-capsule.md index f2fb0b5..79fe629 100644 --- a/text/0000-users-capsule.md +++ b/text/0000-users-capsule.md @@ -158,9 +158,7 @@ will eventually formalize for `cli.v1.command.execute`. Two things to note: ## Storage layout -The capsule's per-principal KV scope holds three key families. Layout is -identical to the legacy `astrid-storage::identity` store so the cutover -reads existing records without migration: +The capsule's per-principal KV scope holds three key families: | Key | Value | Purpose | |---|---|---| @@ -168,6 +166,23 @@ reads existing records without migration: | `link/{platform}/{platform_user_id}` | JSON `FrontendLink` | Platform link, composite key. | | `name/{display_name}` | UTF-8 UUID string | Best-effort display-name lookup index (last writer wins). | +The key scheme matches the legacy `astrid-storage::identity` store +byte-for-byte. The value shapes follow the WIT contract rather than +the kernel's Rust serialization — three deliberate divergences: + +* `AstridUser.public_key` is `option>` (the WIT shape); the + kernel encodes the same bytes as a base64 string. +* `created_at` / `linked_at` are millisecond-precision RFC 3339 + strings; the kernel uses chrono's default microsecond precision. +* `AstridUser` carries no `principal` field. The capsule's per- + principal KV scope already encodes the principal, so the kernel + record's `principal: PrincipalId` is redundant and is dropped on + the first capsule-side re-write. + +Pre-launch there are no production records to migrate. A migration +tool added at cutover time transforms kernel records into capsule +records (base64-decode keys, reformat timestamps, strip principal). + `platform` and `platform_user_id` MUST be validated to reject `/` and `\0` before key construction. Without that gate, a caller passing `platform = "../user"` could collide with `user/{uuid}` records through the From 829e4431c0d819f020645b87980810b6bf1cccd9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 02:48:04 +0400 Subject: [PATCH 3/3] =?UTF-8?q?rfc:=20catch=20up=20to=20merged=20WIT=20?= =?UTF-8?q?=E2=80=94=20two-layer=20identity=20model,=20instance=20scoping,?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the reference-level section against the shipped astrid:users@1.0.0 surface (post unicity-astrid/wit#6): - source.uplink (was source.channel) - platform-instance scoping for Slack/IRC/XMPP (with per-platform table mapping audit findings to wire shapes) - frontend-link.display-name (platform-side global name) - set_display_name / set_public_key topics (UUID-preserving mutation) - paginated list_users, context.list_for_user, context.list_in_context - per-context display-name overlay (ContextIdentity record + 5 context.* topics) - context-aware resolve with three-layer fallback chain - 15 topic pairs total; cascade semantics for unlink and delete_user New sections: 'Identity model' visual, 'Platform-instance scoping' audit table, 'Context-aware resolve' fallback documentation, 'Why a two-layer identity model' rationale, 'Why platform-instance as a dedicated field' rationale. Storage-layout section adds the context/ key family + sentinel rule. Open questions extended with resolve_or_create (filed as astrid#749), merge-users, and the persisting role-markers / authz boundary. --- text/0000-users-capsule.md | 511 +++++++++++++++++++++++++------------ 1 file changed, 347 insertions(+), 164 deletions(-) diff --git a/text/0000-users-capsule.md b/text/0000-users-capsule.md index 79fe629..9341057 100644 --- a/text/0000-users-capsule.md +++ b/text/0000-users-capsule.md @@ -9,14 +9,24 @@ Extract the cross-platform user identity directory out of the kernel into a first-party capsule (`astrid-capsule-users`) that publishes the `astrid:users@1.0.0` IPC interface. The kernel keeps principal-level tenancy; -the capsule owns within-principal user attribution — `AstridUserId`, -`FrontendLink`, and the resolve / link / unlink / create / list surface. +the capsule owns within-principal user attribution. -This RFC defines the IPC topic schema (`users.v1.*`), the request/response -envelope (correlation IDs for multi-tenant uplinks), the KV storage layout the -capsule must use, and the cutover plan that strips the legacy identity surface -out of `astrid-core`, `astrid-storage`, the WASM host fn module, and the -manifest capability declaration. +The interface is built around a **two-layer identity model**: + +1. **Identity layer** — `FrontendLink` maps a platform's stable opaque ID + (Discord snowflake, Telegram user-id, Nostr pubkey, Slack workspace+user) + to a canonical `AstridUserId`. Global per + `(platform, platform-instance, platform-user-id)` triple. +2. **Presentation layer** — `ContextIdentity` overlays a per-context display + name on a link (Discord guild nickname, Matrix room display name, Slack + channel profile). Optional and orthogonal to identity. + +`resolve` is context-aware: returns the layered display name in one +round-trip (context > link > canonical). + +This RFC defines the IPC topic schema (`users.v1.*`), the request envelope, +the KV storage layout, validation invariants, the cutover plan from the +kernel-side surface, and a documented set of follow-up topics. # Motivation [motivation]: #motivation @@ -34,15 +44,31 @@ that rule with the identity subsystem: capability with a three-tier hierarchy (`resolve` / `link` / `admin`). Every new uplink that wants to attribute messages to a user — sphere-capsule -today, Discord/Telegram/web-passkey tomorrow — touches kernel-side types. -That's domain logic in the kernel. +today, Discord/Slack/Telegram/Matrix/Nostr tomorrow — touches kernel-side +types. That's domain logic in the kernel. + +A platform-API audit (Discord, Slack, Telegram, X, Matrix, Mastodon, IRC, +Email, SMS, Nostr, Passkey, GitHub) surfaced two recurring shape mismatches +the legacy kernel store could not express: -The capsule design also unlocks adjacent work: +1. **Instance scoping.** Slack `U01234` in workspace A is not the same human + as `U01234` in workspace B. IRC and XMPP have the same per-network split. + The legacy `(platform, platform-user-id)` composite key cannot express + this without conventions every uplink must agree on by hand. +2. **Per-context presentation.** A Discord user has different nicknames per + guild, a Matrix user different display names per room, a Slack user + different profiles per workspace. Identity is global; presentation is + contextual. Forcing one display name per link loses the per-context view. + +The capsule design takes both into account from 1.0 — they are wire-format +changes that would be breaking to add later. + +The move also unlocks adjacent work: - Uplinks can register new platforms (Nostr, browser extensions, third-party webhooks) without kernel changes. -- Identity-as-domain growth (admin/role markers, denylists, identity - recovery) lives in capsule-space where it belongs. +- Identity-as-domain growth (role markers, denylists, identity recovery) + lives in capsule-space behind a versioned interface contract. - The naming collision with `astrid-capsule-identity` (the *agent's* persona) goes away. @@ -54,94 +80,196 @@ pre-launch: a clean cutover replaces the in-process host fn with IPC RPC. ## What is the users capsule -`astrid-capsule-users` is a first-party capsule that maintains a directory of -canonical `AstridUserId`s and the platform identities linked to them. Every -Astrid principal has its own independent store: different principals never -share user records. +`astrid-capsule-users` is a first-party capsule that maintains a directory +of canonical `AstridUserId`s and the platform identities linked to them. +Every Astrid principal has its own independent store; different principals +never share user records. Within a principal, the store answers questions like: - "Discord user `12345` just messaged me. Who is that?" — `resolve`. - "Pair Telegram chat `tg:abc` with AstridUser `0000…0001`." — `link`. - "List every platform Alice is reachable on." — `links`. +- "What should I call this Discord user in *this* guild right now?" — + `resolve` with `context-id` set. + +The capsule's entire public surface is IPC pub/sub. There is no host +function, no shared in-process state, and no privilege beyond the +per-capsule KV scope every capsule already has. -The capsule's entire public surface is IPC pub/sub. There is no host function, -no shared in-process state, and no special privilege beyond the per-capsule -KV scope every capsule already has. +## Identity model + +``` + AstridUser — canonical Astrid identity (UUID + canonical display_name) + ▲ + │ + │ many-to-one + │ + FrontendLink — global per (platform, instance?, platform_user_id) + Discord snowflake, Telegram user-id, Slack U+T pair, etc. + ▲ + │ + │ many-to-one (one overlay per context) + │ + ContextIdentity — per-context presentation override + Discord per-guild nickname, Matrix per-room display name +``` + +A user has **one** `AstridUser` record, **many** `FrontendLink`s (one per +platform identity), and **optionally many** `ContextIdentity` overlays per +link (one per context the link is presented in). ## How a multi-tenant uplink talks to it -A multi-tenant uplink (one Astrid principal serving N humans — sphere-capsule, -future Discord-capsule) routes responses back to the originating end-user -using correlation IDs. Each request carries a `source` envelope: +A multi-tenant uplink (one Astrid principal serving N humans — +sphere-capsule, future Discord-capsule) routes responses back to the +originating end-user using correlation IDs. Each request carries a `source` +envelope: ```json { - "channel": "discord", + "uplink": "discord", "user-id": "00000000-0000-4000-8000-deadbeefdead", "correlation-id": "9d8a7f3e-..." } ``` -* `channel` — free-form uplink identifier, recorded for audit. +* `uplink` — free-form identifier of the calling capsule (audit, routing). * `user-id` — the requester's own `AstridUserId` when known. `None` for pre-login flows. * `correlation-id` — the token the uplink filters the response topic by. The uplink: -1. Generates a fresh `correlation-id` (UUID is the obvious choice). -2. Publishes `users.v1.resolve.request` with that correlation. -3. Subscribes to `users.v1.resolve.response`. +1. Generates a fresh `correlation-id`. +2. Publishes `users.v1..request` with that correlation. +3. Subscribes to `users.v1..response`. 4. Drops every response whose `correlation-id` does not match. 5. Routes the matched response back to the originating end-user. -The same correlation pattern works across every operation. The response -topic is **fixed** — no per-correlation suffix — so the uplink only needs -one subscription per operation type for its entire lifetime. +The response topic is **fixed** (no per-correlation suffix), so one +long-lived subscription per operation suffices for an uplink's lifetime. ## What lives in the kernel still - `PrincipalId` (the tenancy primitive). - Per-principal KV scoping (the capsule's storage substrate). -- Topic ACL enforcement (the capsule declares `[publish]` and `[subscribe]` - in its manifest; the kernel forbids any other capsule from publishing the - `users.v1.*.response` topics). +- Topic ACL enforcement. # Reference-level explanation [reference-level-explanation]: #reference-level-explanation ## IPC topic surface -| Request topic | Response topic | Operation | +Fifteen request/response pairs across five operation groups. + +**Identity / link CRUD (4):** + +| Request | Response | Operation | |---|---|---| -| `users.v1.resolve.request` | `users.v1.resolve.response` | `(platform, platform-user-id) → option` | -| `users.v1.link.request` | `users.v1.link.response` | Upsert a platform link to an existing user | -| `users.v1.unlink.request` | `users.v1.unlink.response` | Remove a platform link | -| `users.v1.create.request` | `users.v1.create.response` | Create a new `AstridUser` (optionally with display name) | -| `users.v1.links.request` | `users.v1.links.response` | List every link for one user | -| `users.v1.get.request` | `users.v1.get.response` | Fetch a user by UUID | -| `users.v1.delete.request` | `users.v1.delete.response` | Delete a user and cascade every link pointing at it | -| `users.v1.list.request` | `users.v1.list.response` | List every user record in the principal's store | - -Each request carries a `source` envelope (above). Each response echoes the +| `users.v1.resolve.request` | `users.v1.resolve.response` | `(platform, instance?, user-id, context-id?) → option` + layered display name | +| `users.v1.link.request` | `users.v1.link.response` | Upsert a platform link | +| `users.v1.unlink.request` | `users.v1.unlink.response` | Remove a link (cascades context overlays) | +| `users.v1.create.request` | `users.v1.create.response` | Create a new `AstridUser` | + +**AstridUser mutation (2):** + +| Request | Response | Operation | +|---|---|---| +| `users.v1.set_display_name.request` | `users.v1.set_display_name.response` | Change canonical display name without rotating UUID | +| `users.v1.set_public_key.request` | `users.v1.set_public_key.response` | Set / clear public key without rotating UUID | + +**Lookup / admin (4):** + +| Request | Response | Operation | +|---|---|---| +| `users.v1.links.request` | `users.v1.links.response` | List every link for one user | +| `users.v1.get.request` | `users.v1.get.response` | Fetch user by UUID | +| `users.v1.delete.request` | `users.v1.delete.response` | Delete user + cascade links + cascade overlays | +| `users.v1.list.request` | `users.v1.list.response` | Paginated user listing | + +**Per-context presentation overlay (5):** + +| Request | Response | Operation | +|---|---|---| +| `users.v1.context.set.request` | `users.v1.context.set.response` | Upsert a per-context display-name override | +| `users.v1.context.clear.request` | `users.v1.context.clear.response` | Clear one overlay | +| `users.v1.context.get.request` | `users.v1.context.get.response` | Fetch one overlay + resolved AstridUser | +| `users.v1.context.list_for_user.request` | `users.v1.context.list_for_user.response` | Paginated overlays for one user across contexts | +| `users.v1.context.list_in_context.request` | `users.v1.context.list_in_context.response` | Paginated members of one context | + +Each request carries a `source` envelope. Each response echoes the `correlation-id` plus operation-specific success fields and an optional `error` string. ## WIT schema -Defined in [`unicity-astrid/wit/interfaces/users.wit`](https://github.com/unicity-astrid/wit/blob/main/interfaces/users.wit) as the -`astrid:users@1.0.0` package. Records: +Defined in [`unicity-astrid/wit/interfaces/users.wit`](https://github.com/unicity-astrid/wit/blob/main/interfaces/users.wit) as the `astrid:users@1.0.0` package. Records: -- `source` — request envelope (channel, user-id, correlation-id). +- `source` — request envelope `{ uplink, user-id?, correlation-id }`. - `astrid-user` — `{ id, display-name?, public-key?, created-at }`. -- `frontend-link` — `{ platform, platform-user-id, astrid-user-id, linked-at, method }`. -- `{op}-request` / `{op}-response` for every operation above. +- `frontend-link` — `{ platform, platform-instance?, platform-user-id, + astrid-user-id, linked-at, method, display-name? }`. +- `context-identity` — `{ platform, platform-instance?, platform-user-id, + context-id, display-name, updated-at }`. +- `context-member` — `{ context-identity, astrid-user-id? }` (returned by + `context.list_in_context`). +- `{op}-request` / `{op}-response` records for every operation above. The bundle is mirrored into both SDKs via `scripts/sync-contracts-wit.sh`, emitted as `astrid_sdk::contracts::users::*` in Rust and `import { users } from '@astrid-os/sdk/contracts'` in TypeScript. +## Platform-instance scoping + +Federated and multi-tenant platforms (Slack workspace, IRC network, XMPP +server, Mattermost instance) carry an explicit `platform-instance: option` +on every link-keyed record. `None` for globally-scoped platforms (Discord, +Telegram, X) and for federated platforms whose identifier already embeds +the homeserver (Matrix `@alice:server.org`, Mastodon `@alice@server.social`). + +Examples: + +| Platform | platform | platform-instance | platform-user-id | +|---|---|---|---| +| Discord | `"discord"` | `None` | `"123456789012345678"` | +| Slack | `"slack"` | `Some("T01234ABCDE")` | `"U01234ABCDE"` | +| Telegram | `"telegram"` | `None` | `"123456789"` | +| Matrix | `"matrix"` | `None` | `"@alice:server.org"` | +| IRC | `"irc"` | `Some("libera.chat")` | `"alice"` | +| Nostr | `"nostr"` | `None` | `"npub1..."` | +| Email | `"email"` | `None` | `"alice@example.com"` | + +A dedicated field (vs. composing instance into `platform-user-id`) prevents +convention drift across uplinks: two independently-written Slack uplinks +cannot disagree on a separator and silently fail to resolve each other's +records. + +## Context-aware resolve + +`resolve` accepts an optional `context-id`. When set, the response's +`display-name` and `display-name-source` use the first non-empty layer: + +1. `context-identity.display-name` (if `context-id` matches an overlay) +2. `frontend-link.display-name` (the platform's global name at link time) +3. `astrid-user.display-name` (the operator/canonical Astrid name) + +`display-name-source` reports which layer (`"context"`, `"link"`, +`"canonical"`) produced the result, so the caller can audit and the +prompt-builder can rendre the right text for the right context in one +round-trip. + +`context-id` is **opaque to the capsule** — uplinks define their own +schemes: + +| Platform | Suggested `context-id` scheme | +|---|---| +| Discord per-guild | `"guild:{guild-id}"` | +| Matrix per-room | `"room:{room-id}"` | +| Slack per-channel | `"channel:{channel-id}"` (workspace lives in `platform-instance`) | +| Mattermost per-team | `"team:{team-id}"` | +| Single-context platforms (X, SMS) | not used | + ## Source envelope vs. issue #748 The `source` envelope intentionally mirrors the multi-tenant correlation @@ -158,17 +286,44 @@ will eventually formalize for `cli.v1.command.execute`. Two things to note: ## Storage layout -The capsule's per-principal KV scope holds three key families: +The capsule's per-principal KV scope holds four key families: | Key | Value | Purpose | |---|---|---| | `user/{uuid}` | JSON `AstridUser` | Canonical user record. | -| `link/{platform}/{platform_user_id}` | JSON `FrontendLink` | Platform link, composite key. | -| `name/{display_name}` | UTF-8 UUID string | Best-effort display-name lookup index (last writer wins). | +| `link/{platform}/{instance\|_}/{platform-user-id}` | JSON `FrontendLink` | Identity link, composite key. | +| `name/{display-name}` | UTF-8 UUID string | Best-effort display-name lookup index (last writer wins). | +| `context/{platform}/{instance\|_}/{context-id}/{platform-user-id}` | JSON `ContextIdentity` | Per-context overlay. | + +`_` (single underscore) is the sentinel for `platform-instance = None`, +chosen because the validation layer rejects `_` from being a real instance +value, so it cannot collide with a legitimate Slack workspace / IRC network +identifier. + +### Validation invariants + +`platform`, `platform-user-id`, `platform-instance`, and `context-id` are +all validated to reject `/` and `\0` before key construction. Without that +gate, a caller passing `platform = "../user"` could collide with +`user/{uuid}` records through the link path. `platform-instance` additionally +rejects the literal `"_"` (reserved sentinel). + +These are **contract-level** invariants — every consumer that re-implements +this store (a future kernel fallback, a non-Rust port) must enforce them +identically. + +### Cascade semantics + +| Operation | Cascades | +|---|---| +| `unlink` | Every `context/.../{platform-user-id}` overlay attached to that link | +| `delete_user` | Every link pointing at the user, *then* every overlay tied to those links, *then* the name-index entry (only if it still points at the deleted UUID) | + +### Value-shape divergence from the legacy kernel store The key scheme matches the legacy `astrid-storage::identity` store -byte-for-byte. The value shapes follow the WIT contract rather than -the kernel's Rust serialization — three deliberate divergences: +byte-for-byte. Value shapes follow the WIT contract rather than the +kernel's Rust serialization — three deliberate divergences: * `AstridUser.public_key` is `option>` (the WIT shape); the kernel encodes the same bytes as a base64 string. @@ -176,28 +331,28 @@ the kernel's Rust serialization — three deliberate divergences: strings; the kernel uses chrono's default microsecond precision. * `AstridUser` carries no `principal` field. The capsule's per- principal KV scope already encodes the principal, so the kernel - record's `principal: PrincipalId` is redundant and is dropped on - the first capsule-side re-write. + record's `principal: PrincipalId` is redundant and dropped on the + first capsule-side re-write. + +Pre-launch there are no production records to migrate. A migration tool +added at cutover time transforms kernel records into capsule records +(base64-decode public keys, reformat timestamps, strip principal). -Pre-launch there are no production records to migrate. A migration -tool added at cutover time transforms kernel records into capsule -records (base64-decode keys, reformat timestamps, strip principal). +## Pagination -`platform` and `platform_user_id` MUST be validated to reject `/` and `\0` -before key construction. Without that gate, a caller passing -`platform = "../user"` could collide with `user/{uuid}` records through the -link path. The validation is part of the contract, not an implementation -detail — every consumer that re-implements this store (e.g. a future kernel -fallback or a non-Rust port) must enforce it identically. +`list`, `context.list_for_user`, and `context.list_in_context` are +paginated by an opaque `cursor: option` (passed back from prior +`next-cursor`) plus an optional `limit: option` (default 100, +capped at 1000). On the wire pagination is uniform across topics. ## Error semantics -Each response has an optional `error: string` field. A clean "not found" -result (`resolve` of an unlinked platform identity, `get` of a missing UUID) -returns `error = none` with the success field set to its empty form (`user -= none`, `users = []`, etc.). `error` is populated only when the operation -itself failed — input validation, storage errors, or a `UserNotFound` -returned by `link` when the target UUID does not exist. +Each response has an optional `error: string`. A clean "not found" +result (`resolve` of an unlinked identity, `get` of a missing UUID, +`clear_context` of an absent overlay) returns `error = none` with the +success field set to its empty form. `error` is populated only when the +operation itself failed — input validation, storage errors, or +`UserNotFound` returned by mutations targeting a missing UUID. ## Capsule manifest declarations @@ -207,11 +362,11 @@ returned by `link` when the target UUID does not exist. [publish] "users.v1.resolve.response" = { wit = "@unicity-astrid/wit/users/resolve-response" } -# … one entry per operation, eight total. +# … 15 entries, one per operation. [subscribe] "users.v1.resolve.request" = { wit = "@unicity-astrid/wit/users/resolve-request", handler = "handle_resolve" } -# … one entry per operation, eight total. +# … 15 entries, one per operation. ``` No host capabilities are declared. The capsule needs only its per-capsule @@ -232,15 +387,13 @@ wrappers cut over: 4. **Strip kernel-side identity.** Delete: - `astrid-core::identity::{types, mod}` (modules and re-exports). - `astrid-storage::identity` (trait + KV impl). - - `astrid-capsule::engine::wasm::host::identity` (host fn module + 5 fns). + - `astrid-capsule::engine::wasm::host::identity` (host fn module). - `IdentityResolveRequest`/`IdentityLinkRequest`/… records from `contracts/host/astrid-capsule.wit` (kernel host ABI). - - `CapabilitiesDef::identity` and the - `identity_capability_satisfies` hierarchy from - `astrid-capsule::manifest::capabilities`. - - The `[capabilities].identity = ["…"]` declarations on every existing - capsule (replaced with `[publish]`/`[subscribe]` entries on the - `users.v1.*` topics). + - `CapabilitiesDef::identity` + the `identity_capability_satisfies` + hierarchy from `astrid-capsule::manifest::capabilities`. + - Every `[capabilities].identity = ["…"]` declaration (replaced + with `[publish]`/`[subscribe]` entries on the `users.v1.*` topics). Pre-launch, no external consumers. No deprecation window. @@ -248,81 +401,108 @@ Pre-launch, no external consumers. No deprecation window. [drawbacks]: #drawbacks - **Latency.** Identity calls become async IPC RPCs instead of in-process - host fns. The roundtrip cost is one bus hop plus serialization — small, - but non-zero. The SDK wrappers stay sync via subscribe-and-poll-with- - timeout, which adds a per-call subscription churn (or a long-lived - subscription with internal request dispatch). -- **Two-way contract change.** Splitting a host fn into an IPC RPC pair is - a bigger change than tweaking an existing topic. Every uplink that - resolves user IDs has to migrate. Mitigated by pre-launch timing — no - external consumers exist. + host fns. Roundtrip cost is one bus hop plus serialization. Sync SDK + wrappers add a per-call subscription churn (or a long-lived subscription + with internal request dispatch). +- **Two-way contract change.** Splitting a host fn into an IPC RPC pair + is a bigger change than tweaking an existing topic. Mitigated by + pre-launch timing — no external consumers exist. - **Per-principal isolation.** Two principals on the same kernel cannot share user records. The kernel's existing tenancy model already enforces this, but the move makes it explicit: there is no "principal-zero" or cross-principal admin path. If a federation / cross-principal flow is ever needed, it has to be designed top-down. +- **Surface size.** Fifteen topic pairs is larger than the kernel's + five-host-fn surface. Each is a discrete operation; the alternative + (one omnibus `users.v1.exec` topic) trades schema clarity for fewer + ACL entries. We chose discrete topics. # Rationale and alternatives [rationale-and-alternatives]: #rationale-and-alternatives ## Why a capsule, not "fix the kernel module" -Identity-as-domain *will* grow: admin/role markers, denylists, identity -recovery flows. Every new identity feature added to the kernel module +Identity-as-domain *will* grow (admin/role markers, denylists, identity +recovery flows). Every new identity feature added to the kernel module inflates the kernel surface for capsule authors who don't use it. Putting the surface in capsule-space puts evolution behind the same versioned interface contract every other capsule uses. +## Why a two-layer identity model + +Identity is global; presentation is contextual. The same human has one +stable `AstridUserId` everywhere, but how the agent should address them +depends on which Discord guild / Matrix room / Slack channel they're in +right now. + +Three places per-context presentation could live; only one is principled: + +| Approach | Trade-off | +|---|---| +| Encode context into `platform-user-id` (`"guild-A:user-123"`) | One link per (context, user). Resolving "this Discord user anywhere on Discord" requires prefix scans; cross-context unification is ambiguous. | +| Uplink-side cache | Each new uplink reimplements per-context naming. No cross-uplink coherence. The users capsule isn't actually the source of truth. | +| **Two-layer model** (identity link + per-context overlay) | More surface, but the only design where "who am I talking to" and "what do I call them right now" both have a single owner. | + +Per-context display **belongs in this capsule** because it lets `resolve` +return the right name in one round-trip; pushing it uplink-side means the +prompt-builder hops twice. + ## Why fixed response topics + correlation IDs -Two designs were considered for the response routing: +Two designs were considered: 1. **Per-request response topic.** Request payload carries `response_topic = "users.v1.resolve.response."`; capsule - publishes there. Used by `prompt_builder.v1.hook.before_build`. + publishes there. 2. **Fixed response topic + correlation ID** (chosen). Capsule always publishes to `users.v1.resolve.response`; requester filters by `correlation-id`. -The fixed-topic design is cheaper for the requester: one long-lived -subscription per operation type instead of one short-lived subscription per -inflight request. For high-rate operations (`resolve` on every incoming -message), the difference adds up. Per-request topics also create a fan-out -problem in topic ACL evaluation — the kernel walks an ACL set every publish, -and a per-correlation-ID topic name churns the ACL cache. +Fixed-topic is cheaper for the requester: one long-lived subscription per +operation type instead of one short-lived subscription per inflight +request. For high-rate operations (`resolve` on every incoming message), +the difference adds up. Per-request topics also create a fan-out problem +in topic ACL evaluation — the kernel walks an ACL set every publish, and +a per-correlation-ID topic name churns the ACL cache. The downside is that every subscriber sees every response. Within a principal, this is fine — same-principal capsules can already see each -other's traffic, and the correlation-ID filter is trivially cheap. Across -principals, the per-principal KV scope keeps stores isolated regardless. +other's traffic, and the correlation-ID filter is trivially cheap. + +## Why `platform-instance` as a dedicated field -## Why include `get` / `delete` / `list` beyond the issue's stated five +Two designs considered for Slack-style workspace scoping: -The kernel's `IdentityStore` trait already has `get_user`, `get_user_by_name`, -`delete_user`, and `list_users`. The kernel uses `delete_user` from the -admin handlers (`kernel_router::admin::handlers`). Without these in the -capsule surface, the cutover would leave the admin handlers in a partial -state, OR the SDK would need a fallback path. Including them now keeps the -migration mechanical. +1. **Convention**: uplinks compose `platform-user-id = "{workspace}.{user}"`. + Cheap, no schema change. Drift risk: every Slack uplink must agree + on the separator or resolution breaks across uplinks. +2. **Dedicated field**: `platform-instance: option` (chosen). + Explicit, never ambiguous. Cost: one optional field on every record. -`get_user_by_name` is the lone holdout — it's an internal kernel call site, -not part of the SDK surface, and the name index is best-effort -last-writer-wins. The capsule keeps the underlying KV entry but doesn't -expose a topic for it. Add later if a use case appears. +Slack is a real near-term target; one field, one-time cost, prevents an +entire class of resolution bugs. -## Why a `source` envelope on every request, not just where uplinks need it +## Why include `get` / `delete` / `list` / `set_*` beyond a minimal CRUD -Three options: +The kernel's `IdentityStore` already has `get_user`, `delete_user`, and +`list_users`. The kernel uses `delete_user` from admin handlers. Without +these in the capsule surface, the cutover would leave the admin handlers +in a partial state. `set_display_name` and `set_public_key` close the +mutation gap the legacy store didn't have but that real lifecycle needs +(name changes, key rotation) — without rotating the UUID and breaking +every existing link. -1. Optional `source` on every request, fill in only where needed. -2. Required `source` on every request, with sentinel values - (`channel = "system"`, `correlation-id = "—"`). -3. Two parallel topic namespaces (single-tenant vs. multi-tenant). +## Why a `source` envelope on every request -(3) doubles the surface — not worth it. (1) makes the protocol depend on -which operations happen to have multi-tenant callers today, which is the -brittle option. (2) is simple and forces every caller to think about -attribution. +Three options considered: + +1. Optional `source`, fill in only where needed. Makes the protocol depend + on which operations have multi-tenant callers today, which is brittle. +2. Required `source` with sentinel values (`uplink = "system"`, + `correlation-id = "—"`) — chosen. Forces every caller to think about + attribution. +3. Two parallel topic namespaces (single-tenant vs. multi-tenant). Doubles + the surface — not worth it. # Prior art [prior-art]: #prior-art @@ -332,58 +512,61 @@ attribution. architectural decision: identity-as-domain is its own service, not part of the routing kernel. - **OAuth resource servers** — opaque identifier mapping is owned by a - resource server, not the authorization server. Astrid's split mirrors - this: principal tenancy (kernel) is distinct from within-principal - identity directory (capsule). + resource server, not the authorization server. - **`urn:` URN registries** — platform-namespaced identifier mapping is a - well-trodden pattern; this RFC reinvents none of that, just declares - `platform` as the namespace and `platform_user_id` as the opaque value. + well-trodden pattern; this RFC reinvents none of that. +- **IRC NickServ / Slack profiles** — per-server/per-workspace identity + registries are the same kind of overlay design. # Unresolved questions [unresolved-questions]: #unresolved-questions -- **Auto-create on first resolve.** Should the capsule expose a - `resolve-or-create` topic that handles "I see Discord user `12345`, - give me an `AstridUser` (creating one if needed)" in one round trip? - Today the caller does `resolve` then `create` then `link` — three - hops. Likely yes; punt to a follow-up RFC once a concrete consumer - needs it. -- **Role markers / ACLs.** The issue floats a `principal_relation` enum - (`admin` / `peer` / `unknown`) on `FrontendLink`. This RFC does not - include it. The intent is to add a separate `astrid:authz@1.0.0` - interface that consumes `users.v1.*` outputs, rather than baking - policy into the identity record itself. Open question whether that - separate interface lives in this capsule or another. -- **SDK sync vs async.** The kernel-side host fn is synchronous from - the caller's perspective. IPC RPC is inherently async (publish, - await response). The SDK can: - 1. Keep sync semantics — internal subscribe + poll loop with timeout. - 2. Expose async fns — cleaner, matches reality. - Recommend (2) for greenfield code; expose a (1) facade for the +- **`resolve_or_create` follow-up.** Filed as [astrid#749](https://github.com/unicity-astrid/astrid/issues/749). + Common-case bootstrap is currently three round-trips (resolve → create + → link); the new topic collapses it to one. Atomic-from-the-uplink's-POV, + avoids the orphan-user-on-step-3-failure case. +- **Role markers / ACLs.** A `principal-relation` enum + (`admin` / `peer` / `unknown`) could go on `FrontendLink`, OR be owned + by a separate `astrid:authz@1.0.0` interface that consumes + `users.v1.*` outputs. Current intent: separate interface. Open which. +- **SDK sync vs async.** The kernel-side host fn is synchronous from the + caller's perspective. IPC RPC is inherently async. The SDK can keep + sync semantics (internal subscribe + poll loop with timeout) or expose + async fns. Recommend async for greenfield; offer a sync facade for the cutover so existing call sites don't all rewrite at once. -- **#748 envelope hoisting.** If #748's final envelope shape diverges - from `users.wit`'s, the hoist becomes a breaking change against - `astrid:users@1.0.0`. Mitigated by keeping the field names identical - in both drafts; revisit at #748 merge. +- **`merge-users` operation.** If an uplink creates a duplicate + `AstridUser` before discovering the existing one, recovery today is + `unlink-everything → delete-user → re-link` per platform. Works, but + operators will hit this. A `merge` topic would cascade reassignment. +- **#748 envelope hoisting.** If #748's final shape diverges from + `users.wit`'s `source`, the hoist becomes a breaking change. Mitigated + by keeping field names identical in both drafts; revisit at #748 merge. # Future possibilities [future-possibilities]: #future-possibilities +- **`resolve_or_create.request` topic** — atomic bootstrap (astrid#749). +- **`resolve_many.request`** — batch lookup for group-member roster + queries that today require N round-trips. +- **Cryptographic link verification** — a `users.v1.verify.request` flow + where the platform-side endpoint signs a challenge to attest a link + claim. Today `method` is audit-only. +- **Mutation event stream** — fan out every link/unlink/create/delete/ + set_* / context.* to `users.v1.audit.event` so audit capsules observe + the full mutation stream without polling. +- **Public-key authentication.** `AstridUser.public_key` is reserved in + the type but unused. A future RFC defines verification flow (e.g. + ed25519 signed payload authentication). - **Federation / cross-principal lookup.** A separate capsule could publish federation requests across principals (with appropriate - cross-principal capability gating). The users capsule would not change - — federation is a layer above identity. -- **Webhook-driven link verification.** A future `users.v1.verify.request` - could carry a cryptographic challenge the platform-side endpoint signs. - This RFC explicitly does not include verification — `method` is just a - free-form audit string today. -- **Capsule-to-capsule queries with explicit grants.** Once the - authorization story (above) lands, an admin capsule could be granted - read-only access to `users.v1.list.*` for reporting, while consumer - capsules only see `resolve`. -- **Audit log topic.** Every mutation (`link`, `unlink`, `create`, - `delete`) could fan out to `users.v1.audit.event` so an audit capsule - observes the full mutation stream without polling. Out of scope here. -- **Public-key authentication.** `AstridUser.public_key` is reserved in - the type but unused by this RFC. A future RFC defines the verification - flow (e.g. ed25519 signed `users.v1.verify.request` payloads). + cross-principal capability gating). The users capsule would not change. +- **`get_by_display_name` topic.** The kernel had this internally; not + exposed today. Add if a real use case appears. +- **Capsule-to-capsule queries with explicit grants.** Once the authz + story (above) lands, an admin capsule could be granted read-only access + to `users.v1.list.*` for reporting while consumer capsules only see + `resolve`. + +Group/room/channel entities are explicitly **not** modelled in this +capsule — they are routing context, not identity. If a real need surfaces, +they live in a separate `astrid:groups@1.0.0` capsule.