Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/specs/delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Fabric Identities

This document addresses future plans for identity and permissions for the Common Tools fabric, and the process to get there.

## URL structure

Via latest [PRD](https://docs.google.com/document/d/1KixOc7L5LZ8IdJtO_9pNHohPg_LXEF5hFgoAzvq9KLc/edit?tab=t.vx0btmvvbit9), the URL structure represents the three address component types:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a few comments there, but the most relevant is just to suggest that the top-level part must resolve to a space (either by being a DID, a DNS lookup or using the provider resolution), and then each subsequent component can be a lookup in that space that leads to another space, and so on.

i also added a comment there proposing that the default way to resolve components in a space is by deriving a URI from the slug and the space DID, which can then contain a link to the actual charm, or a space redirect. see below on how that maps to the CREATE capability.


```
DID = "did:key:{string}"
DOMAIN = "{string.}*{string}.{string}"
CHARM_ID = "of:${HASH}"
NAMESPACE = DID | DOMAIN
SPACE = DID | string;
CHARM = CHARM_ID | string;
ADDR = "/{@NAMESPACE/}?SPACE/CHARM?"
```

Each component may be a slug/name ("my-notes-123") or a DID key ("did:key:abc..").
Namespaces are optional in the URL, and are prefixed with a "@" followed by either
a DID key or DNS address. If namespace not provided, then an implicit, global provider-namespace is used.

## Namespace

Namespaces are a scope of spaces, and spaces contain charms.

Namespaces can be referenced via DNS record, resolving to a DID key, or via directly as a DID key. Similarly, a provider-namespace is implied when no namespace given, which also resolves to an identity DID.

Namespaces manage many spaces, and are responsible from mapping space petnames to their identity.
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace “responsible from” with “responsible for” so the sentence reads correctly and the responsibility is unambiguous.

Prompt for AI agents
Address the following comment on docs/specs/delegation.md at line 29:

<comment>Replace “responsible from” with “responsible for” so the sentence reads correctly and the responsibility is unambiguous.</comment>

<file context>
@@ -0,0 +1,110 @@
+
+Namespaces can be referenced via DNS record, resolving to a DID key, or via directly as a DID key. Similarly, a provider-namespace is implied when no namespace given, which also resolves to an identity DID. 
+
+Namespaces manage many spaces, and are responsible from mapping space petnames to their identity.
+Each namespace has an &quot;admin&quot; space (possibly with the same identity as the namespace itself?), where records are stored.
+
</file context>
Suggested change
Namespaces manage many spaces, and are responsible from mapping space petnames to their identity.
Namespaces manage many spaces, and are responsible for mapping space petnames to their identity.
Fix with Cubic

Each namespace has an "admin" space (possibly with the same identity as the namespace itself?), where records are stored.

> [!NOTE]
> There may be a way to view a namespace, like itemizing all contained spaces, but is out of scope here.

Additionally, delegations for access to the namespace is stored in the admin space as well. See **CAPABILITIES** below for delegation.
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the plural verb “are” so the subject-verb agreement is correct and the sentence reads naturally.

Prompt for AI agents
Address the following comment on docs/specs/delegation.md at line 35:

<comment>Use the plural verb “are” so the subject-verb agreement is correct and the sentence reads naturally.</comment>

<file context>
@@ -0,0 +1,110 @@
+&gt; [!NOTE]
+&gt; There may be a way to view a namespace, like itemizing all contained spaces, but is out of scope here.
+
+Additionally, delegations for access to the namespace is stored in the admin space as well. See **CAPABILITIES** below for delegation.
+
+### Namespace Admin Space
</file context>
Suggested change
Additionally, delegations for access to the namespace is stored in the admin space as well. See **CAPABILITIES** below for delegation.
Additionally, delegations for access to the namespace are stored in the admin space as well. See **CAPABILITIES** below for delegation.
Fix with Cubic


### Namespace Admin Space

```
DELEGATIONS = UCAN[];
// Mapping of space name to space identity
ITEMS = Map<string, DidKey>
```

### Provider Namespace

The provider namespace is local to a provider service, and the default namespace used when none supplied. This functions like other user-owned namespaces, except owned by the provider.

## Space

Each space contains many charms and other data. A space is the root of permissions in the system, and capabilities are applied per space. Similar to namespaces, each space must maintain a mapping of charm names to identities, as well as permission for the space. Unlike namespaces, this data is stored in well-known Cells rather than a derivable space.

### Space Admin Space

```
DELEGATIONS = UCAN[];
// Mapping of charm name to charm identity
ITEMS = Map<string, DidKey>
```

## Capabilities

Access and permissions are handled via an authorization token like [UCAN](https://github.com/ucan-wg/spec) or another alternative. These tokens represent capabilities delegated to identities for a given subject.

* `SPACE:CREATE`: A namespace-based capability indicating permission to create a new space. This is the only delegation stored in a namespace admin record.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, interestingly i think this could just correspond to the ability to create URIs whose hash is just derived from the space did, not some other URI that's already in the space. (assumes we restrict this, which we currently do, but sometimes consider since it guarantees unforgeable identifiers)

* `SPACE:READ`: Permission to read a space.
* `SPACE:WRITE`: Permission to write data to a space. A superset of `SPACE:READ`.
* `SPACE:OWNER`: Permission to modify authorization in a space. A superset of `SPACE:WRITE`.

> [!NOTE]
> Revocations and rotations are currently out of scope, but could be handled with delegation.

### The `ANYONE` User

We use a well-known key to assign delegation to all users.

### Example

Alice (`did:key:alice`) creates a new space on `provider.com` (with identity `did:key:provider`) with the name `alice-space`. No namespace was provided, so the *provider namespace* is used. The space name with identity `did:key:provider` (possibly a derivation of identity) is referenced to ensure Alice has permission to create a new space, and that `alice-space` is an available space name.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a bunch in the PRD about provider-based names being disambiguated with an appended short hash, hopefully making them globally unique. maybe that happens as part of creating the key (see next paragraph)


`provider.com` was configured to allow all users to create a new space by delegating the `SPACE:CREATE` capability to the `ANYONE` identity, stored in the `did:key:provider` space's delegations. Additionally, there are no current spaces with the name `alice-space`. The provider creates the space on Alice's behalf, generating a new key for the space (`did:key:alice-space`). This key is stored by the provider, immediately delegating `SPACE:OWNER` capabilities to `did:key:alice`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think that's implicit, but that delegation also removes did:key:alice-space from the owner list, right? i.e. if there is no ACL then the assumption is OWNER:, and the typical bootstrapping flow replaces that with others, taking the original DID out?


Now, `provider.com` is hosting `alice-space` in the provider namespace. No one else yet has any access to data stored in this space. Alice wants to invite Bob (`did:key:bob`) to this space, and signs a new delegation with `did:key:alice` for `did:key:bob`, granting Bob `SPACE:WRITE` permissions. Bob can now read and write data to the space, but cannot invite Eve, lacking the `SPACE:OWNER` capability.

Similarly, in a non-provider namespace (e.g. `@alice.fab.com`, or `@did:key:alice`), the namespace is created, immediately assigning `SPACE:CREATE` capabilities to `did:key:alice`, or whatever key `@alice.fab.com` resolves to. No other identities may create spaces within Alice's namespace, unless they were to e.g. delegate `SPACE:CREATE` capabilities to another user.

## Plan

Currently, `Session`s are created in the workspace, and the abstraction between public/private spaces needs updated. There are a few areas currently that will need to use a new Session compatible with this document:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can start with today's derived-from-name spaces for now and focus on adding ACLs.

Effectively ~ partitions the namespaces per user, but we could just choose to not use that.

In a way it's the most simple implementation of provider-level lookup and we can just grow it from there.


### Manual Private Space

* charm/src/ops/charms-controller.ts
* shell/src/lib/runtime.ts
* cli/lib/charm.ts

### Uses Admin Session

* background-charm-service/src/worker.ts
* background-charm-service/cast-admin.ts

### Steps

* Update Session interface with this address proposal, apply to codebase
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as noted above, it might be sufficient to go straight to the next step, at least to start.

* Sign transactions with correct identity, not the anonymous user.
* Store delegations when creating a new space.
* Verify transactions against the signer and space's delegations.
* [UX] Option to create new space as public-write (all users have write access(?)) public (delegating read permissions to all users, write permissions to owner), or private (only delegates read/write access to owner)


65 changes: 65 additions & 0 deletions packages/identity/src/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type Domain = string; // `fab.space`, `alice.fab.space`
export type DidKey = `did:key:${string}`;
export type Namespace = Domain | DidKey;
export type SpaceName = string | DidKey;
export type CharmName = string | DidKey;

export type AddrComponent = {
name: string;
did: undefined;
} | {
name: undefined;
did: DidKey;
};

export type CharmAddress = {
namespace?: AddrComponent;
space?: AddrComponent;
charm?: AddrComponent;
};

class CharmAddressError extends Error {
constructor(message?: string) {
super(message ?? "Invalid address.");
}
}

export function parseCharmAddress(url: URL): CharmAddress {
const [prefix, ns, space, charm] = url.pathname.split("/");
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parser drops any path segments beyond the first three address components, so a malformed URL such as "/@ns/space/charm/extra" is treated as valid instead of throwing. Please reject paths that contain additional segments after the charm component.

Prompt for AI agents
Address the following comment on packages/identity/src/url.ts at line 28:

<comment>The parser drops any path segments beyond the first three address components, so a malformed URL such as &quot;/@ns/space/charm/extra&quot; is treated as valid instead of throwing. Please reject paths that contain additional segments after the charm component.</comment>

<file context>
@@ -0,0 +1,65 @@
+}
+
+export function parseCharmAddress(url: URL): CharmAddress {
+  const [prefix, ns, space, charm] = url.pathname.split(&quot;/&quot;);
+  if (prefix || !ns) throw new CharmAddressError();
+  if (!space &amp;&amp; charm) throw new CharmAddressError();
</file context>
Fix with Cubic

if (prefix || !ns) throw new CharmAddressError();
if (!space && charm) throw new CharmAddressError();
// First component has `@`, this is a namespace
if (ns.startsWith("@")) {
return {
namespace: newAddrComponent(ns.substring(1)),
space: space ? newAddrComponent(space) : undefined,
charm: charm ? newAddrComponent(charm) : undefined,
};
}
// First component does not have `@`, this is a shared
// space global to the provider.
return {
namespace: undefined,
space: newAddrComponent(ns),
charm: space ? newAddrComponent(space) : undefined,
};
}

export function parseCharmAddressFromString(url: string): CharmAddress {
return parseCharmAddress(new URL(url));
}

export function isDidKey(value: unknown): value is DidKey {
return typeof value === "string" && value.startsWith("did:key:") &&
value.length === 56;
}

export function newAddrComponent(value: unknown) {
if (typeof value !== "string" || value === "") throw new CharmAddressError();
if (isDidKey(value)) {
return { did: value, name: undefined };
}
// Throw if valid resembles a did:key: without being one
if (value.startsWith("did:key:")) throw new CharmAddressError();
return { did: undefined, name: value };
}
122 changes: 122 additions & 0 deletions packages/identity/test/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { assertObjectMatch, assertThrows } from "@std/assert";
import { CharmAddress, DidKey, parseCharmAddress } from "../src/url.ts";

const BASE = new URL("http://foo.com");
const SPACE_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsSPACE";
const CHARM_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsCHARM";
const ALICE_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsALICE";

const VALID: [string, CharmAddress][] = [
["/globalspace", {
namespace: undefined,
space: name("globalspace"),
charm: undefined,
}],
["/globalspace/charm", {
namespace: undefined,
space: name("globalspace"),
charm: name("charm"),
}],
["/globalspace/charm", {
namespace: undefined,
space: name("globalspace"),
charm: name("charm"),
}],
[`/${SPACE_KEY}/charm`, {
namespace: undefined,
space: did(SPACE_KEY),
charm: name("charm"),
}],
[`/${SPACE_KEY}/${CHARM_KEY}`, {
namespace: undefined,
space: did(SPACE_KEY),
charm: did(CHARM_KEY),
}],
[`/globalspace/${CHARM_KEY}`, {
namespace: undefined,
space: name("globalspace"),
charm: did(CHARM_KEY),
}],
[`/${SPACE_KEY}`, {
namespace: undefined,
space: did(SPACE_KEY),
charm: undefined,
}],
[`/@${ALICE_KEY}`, {
namespace: did(ALICE_KEY),
space: undefined,
charm: undefined,
}],
[`/@${ALICE_KEY}/${SPACE_KEY}`, {
namespace: did(ALICE_KEY),
space: did(SPACE_KEY),
charm: undefined,
}],
[`/@${ALICE_KEY}/${SPACE_KEY}/${CHARM_KEY}`, {
namespace: did(ALICE_KEY),
space: did(SPACE_KEY),
charm: did(CHARM_KEY),
}],
[`/@${ALICE_KEY}/space/${CHARM_KEY}`, {
namespace: did(ALICE_KEY),
space: name("space"),
charm: did(CHARM_KEY),
}],
[`/@${ALICE_KEY}/${SPACE_KEY}/charm`, {
namespace: did(ALICE_KEY),
space: did(SPACE_KEY),
charm: name("charm"),
}],
[`/@alice.fab.com/${SPACE_KEY}/charm`, {
namespace: name("alice.fab.com"),
space: did(SPACE_KEY),
charm: name("charm"),
}],
["/@namespace/space/", {
namespace: name("namespace"),
space: name("space"),
charm: undefined,
}],
];

const INVALID: string[] = [
"/", // Empty pathname - no first component
"", // Empty string
"//space", // Empty first component
"/space//charm", // Empty second component
"/@namespace//charm", // Empty space component with namespace
"/@", // Namespace marker only, no value
"/@/space", // Empty namespace after @
"/did:key:", // Malformed DID - no value after did:key:
"/@did:key:/space", // Empty DID in namespace
"/space/did:key:", // Empty DID in charm
];

Deno.test("Parses correct CharmAddress", () => {
for (const [url, expectation] of VALID) {
assertObjectMatch(
parseCharmAddress(new URL(url, BASE)),
expectation,
`"${url}" is a valid address.`,
);
}
});

Deno.test("Throws error on invalid CharmAddress", () => {
for (const url of INVALID) {
assertThrows(
() => parseCharmAddress(new URL(url, BASE)),
Error,
"Invalid address",
`"${url}" is an invalid address.`,
);
}
});

function did(value: DidKey) {
return { did: value, name: undefined };
}

function name(value: string) {
return { did: undefined, name: value };
}