Skip to content
Merged
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
42 changes: 20 additions & 22 deletions CLAUDE.md

Large diffs are not rendered by default.

153 changes: 153 additions & 0 deletions TNT-244_RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# TNT-244 Release Runbook

TNT-244 removes agent principals, agent API keys, and impersonation from Memory
Engine. Release all committed phases together. Do not deploy an intermediate
commit.

## Before Release

1. Confirm normal production backups have completed.
2. Notify the four holders of retired agent API keys. Their keys will stop
working after the migration; direct them to use a user PAT or service-account
key as appropriate.
3. Record the current agent and agent-key inventory:

```sql
-- (a) the agents themselves
select p.id, p.name, p.owner_id
from core.principal p
where p.kind = 'a'
order by p.name;

-- (b) the api keys bound to those agents
select k.id, k.name, k.member_id, k.restricted
from core.api_key k
join core.principal p on p.id = k.member_id
where p.kind = 'a'
order by k.name;

-- (c) restricted-key declarations that will cascade with the key row
select s.api_key_id, s.space_id, s.space_admin
from core.api_key_space_access s
join core.api_key k on k.id = s.api_key_id
join core.principal p on p.id = k.member_id
where p.kind = 'a';

select t.api_key_id, t.space_id, t.tree_path::text, t.access
from core.api_key_tree_access t
join core.api_key k on k.id = t.api_key_id
join core.principal p on p.id = k.member_id
where p.kind = 'a';
```

4. Record the memory count at every retired agent-home path. These rows must
remain in their existing paths after the migration. For each `me_<slug>`
space schema and each `(owner_id, agent_id)` from step 3(a), run a variant
of the following (agent-home paths follow `home.<hex_owner>.<hex_agent>`
with hyphens stripped):

```sql
-- Replace <slug>, <hex_owner>, <hex_agent> as needed.
-- `<@` is ltree "descendant of", so this counts the home node itself
-- plus everything beneath it.
select tree::text, count(*) as memory_count
from me_<slug>.memory
where tree <@ 'home.<hex_owner>.<hex_agent>'::ltree
group by tree
order by tree;
```

For a scripted sweep across every space, iterate over
`select slug from core.space` and paste the resulting counts into the
release ticket for step 3 of "After Release" to compare against.

5. Confirm expected user, group, and service-account access with a representative
account in each production space.

6. **Effective-admin preflight — must return 0.** Application code has never
granted an agent `principal_space.admin`, but the DB does not forbid it.
Migration 018 deletes agent principals in one statement, which fires the
deferred `enforce_last_admin` trigger; if any space's *only* remaining
effective admin were an agent, the trigger would abort the migration with
`LAST_ADMIN`. Verify pre-emptively:

```sql
select count(*)
from core.principal_space ps
join core.principal p on p.id = ps.principal_id
where ps.admin and p.kind = 'a';
```

Any non-zero result means a space would otherwise be left without a user
admin — resolve by promoting a user admin (`me group set-space-admin`, or
a direct roster admin row) before the migration runs.

## Release

1. Merge the complete TNT-244 change set.
2. Run the server release as `0.7.0`. **At the `Bump MIN_CLIENT_VERSION?`
prompt, enter `0.7.0`** — do not press Enter to keep the default. This is
what makes older 0.6.x clients incompatible with the new server, matching
step 4's expectation.
3. Run the client release as `0.7.0`. **At the `Bump MIN_SERVER_VERSION?`
prompt, enter `0.7.0`** — again, do not press Enter to keep.
4. Treat the two releases as one maintenance-window deployment. Versioned
`0.6.2` clients are intentionally incompatible with the new server;
clients without a version header remain subject to the existing lenient
behavior.

**Maintenance-window lock note.** Migration 018 issues
`ALTER TABLE core.principal ALTER COLUMN member_id SET EXPRESSION AS (...)`,
which takes an ACCESS EXCLUSIVE lock on `core.principal` and rewrites the
table (all row values recompute to the same values in practice, so it is
short — but it is a full-table event). This is why the two package releases
are done inside one maintenance window.

## After Release

1. Verify no agent principals remain:

```sql
select count(*) from core.principal where kind = 'a';
```

2. Verify no API keys belong to retired agents (and no scoped declarations
linger):

```sql
select count(*)
from core.api_key k
join core.principal p on p.id = k.member_id
where p.kind = 'a';

-- Both should also be 0 (cascaded from api_key.id):
select count(*) from core.api_key_space_access s
where not exists (select 1 from core.api_key k where k.id = s.api_key_id);
select count(*) from core.api_key_tree_access t
where not exists (select 1 from core.api_key k where k.id = t.api_key_id);
```

3. Recheck the recorded agent-home memory counts and paths (step 4 above).
Each `(tree, count)` pair must match the pre-release capture; no rows
should have moved or been rewritten.

4. Verify no `core.tree_access` row references a deleted principal (a smoke
check on the cascade — should always be 0):

```sql
select count(*) from core.tree_access ta
where not exists (select 1 from core.principal p where p.id = ta.principal_id);
```

5. Verify normal user-session, restricted-PAT, and service-account-key access
with MCP and CLI operations.

6. Confirm legacy `X-Me-As-Agent` is ignored, and that old agent keys receive
the normal invalid-key error.

7. Watch error logs and authentication failures during the maintenance window.

## Rollback

Do not attempt a schema-only rollback after migration 018 has run. Restore from
the normal production backup if rollback is required.
45 changes: 16 additions & 29 deletions docs/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,23 @@ Memory Engine organizes knowledge into **spaces**. Access within a space is gran

## Principals

A **principal** is anything that can be granted access. There are four kinds:
A **principal** is anything that can be granted access. There are three kinds:

| Kind | What it is |
|------|------------|
| **user** (`u`) | A human, authenticated by a session token (OAuth via GitHub or Google). |
| **agent** (`a`) | A non-human agent owned by one user, authenticated by an API key. Its effective access is clamped to its owner's. |
| **service account** (`s`) | A space-scoped operational identity for CI/CD, webhooks, and team-owned integrations. It authenticates by API key and has no owner clamp. |
| **group** (`g`) | A named bundle of users, agents, and service accounts. |
| **group** (`g`) | A named bundle of users and service accounts. |

A **member** is the user/agent/service-account sense only — the things that can be put into a group or hold an API key. Group membership does **not** by itself confer space membership: a group's grants (and its admin, if it's an admin group) apply to you only once you have **also** joined the space directly. So an admin can add you to a group before you join — the group's access stays dormant until you do.
A **member** is the user/service-account sense only — the things that can be put into a group or hold an API key. Group membership does **not** by itself confer space membership: a group's grants (and its admin, if it's an admin group) apply to you only once you have **also** joined the space directly. So an admin can add you to a group before you join — the group's access stays dormant until you do.

A group is itself part of its space's roster (it gets a roster entry when created), which is what lets you grant access to it or reference it **by name**. That roster entry is the group's own; it is separate from membership conferral, which still depends on each user/agent/service-account having joined the space directly.
A group is itself part of its space's roster (it gets a roster entry when created), which is what lets you grant access to it or reference it **by name**. That roster entry is the group's own; it is separate from membership conferral, which still depends on each user/service-account having joined the space directly.

### Service accounts

A **service account** is a durable operational identity administered by a team, not by one human owner. Create one with [`me service`](cli/me-service.md), then mint a key with `me apikey create --service <service>`. Service accounts are useful for CI/CD jobs, importers, webhooks, and other team-owned integrations.

Each service account has a **bound admin group**. Space admins can manage all service accounts; direct user members of that bound admin group can administer that service account — renaming it, deleting it, and managing its API keys. Users, agents, and service accounts may all be members of the bound group, but only users count as service-account admins. The service account is not automatically added to its own bound admin group, and grants to that group behave like ordinary group grants.
Each service account has a **bound admin group**. Space admins can manage all service accounts; direct user members of that bound admin group can administer that service account — renaming it, deleting it, and managing its API keys. Users and service accounts may be members of the bound group, but only users count as service-account admins. The service account is not automatically added to its own bound admin group, and grants to that group behave like ordinary group grants.

Service accounts start with **zero tree access**: no home grant, no default-group membership, and no owner clamp. Grant access to the service account directly, or add it to ordinary groups. A service-account key can use the memory and group/grant authorities the service account actually holds. By default it cannot create invitations; if you explicitly make the service account a space admin, it can use admin-gated invitation operations. It still cannot mint or revoke API keys or delete spaces.

Expand All @@ -35,7 +34,7 @@ An API key created with [`me apikey create --allow`](cli/me-apikey.md#me-apikey-
- `--space-admin <space>` permits space-admin authority only when the holder is also an effective admin in that space.
- Restricted declarations are immutable. Rotate the key: create a replacement, deploy and verify it, then revoke the old key.

Agent keys cannot be restricted; use an agent's regular grants to limit an agent. Restricted user PATs cannot manage the account, including minting, revoking, or inspecting API keys.
Restricted user PATs cannot manage the account, including minting, revoking, or inspecting API keys.

## Spaces

Expand All @@ -50,7 +49,7 @@ A user can belong to many spaces; each memory lives in exactly one space. There

Access splits into two independent axes:

- **Structural authority** — `me space invite`, the roster (`me agent add`, `me service ...`, `me group ...`), and invitations. This is the space **admin** flag. Admin transfers through an **admin group** to its members who are also direct space members. Designate one with `me group create <name> --space-admin` or `me group set-space-admin <group>` (revoke with `--off`); `me group list` shows which groups are admin groups. Agents are never admins. Service accounts can be made space admins explicitly, but this is discouraged and they do not count toward the last-admin safeguard.
- **Structural authority** — `me space invite`, the roster (`me service ...`, `me group ...`), and invitations. This is the space **admin** flag. Admin transfers through an **admin group** to its members who are also direct space members. Designate one with `me group create <name> --space-admin` or `me group set-space-admin <group>` (revoke with `--off`); `me group list` shows which groups are admin groups. Service accounts can be made space admins explicitly, but this is discouraged and they do not count toward the last-admin safeguard.
- **Data authority** — who can read/write/own memories at a given tree path. This is a **tree-access grant**.

A space must always keep at least one *effective* human admin (a user who is a direct admin or a direct member of an admin group). The last-admin safeguard rejects any removal or demotion that would drop it (error code `LAST_ADMIN`).
Expand Down Expand Up @@ -88,32 +87,24 @@ me access rm-grant bob@example.com /share/work/backend

The level argument accepts `r` (read), `w` (write), or `o` (owner).

### Granting your own agents

Managing a grant normally requires **owner** at the path. The exception is your
own **agents**: an agent's effective access is always clamped to its owner's, so
you can never give an agent more than you hold. Because of that you may grant or
revoke access for an agent you own at **any** path — even one you don't own —
without holding an owner grant there. The clamp keeps it honest: grant the agent
a higher level than you hold and it clamps down to yours; grant it a path you
have no access to and the agent simply gets nothing. This lets you scope an
agent to just the part of a subtree it needs, even on shared trees you don't own.

Service accounts do **not** use this exception. They have no owner clamp, so granting access to a service account requires the normal authority: space admin or `owner` at the path. Revoking access from a service account is also allowed for space admins and direct user members of that service account's bound admin group.
Managing a grant requires **space admin** or **`owner`** at the path (admin
bypasses the path-owner check; an admin can always self-grant `owner@root`).
Revoking access from a service account is additionally allowed for direct user
members of that service account's bound admin group.

## Reserved tree roots

Every space has two conventional roots:

- **`/share`** — the shared root. Memories everyone in the space should see go here. This is where the file importers default a tree-less record, and where `me memory create` / `me_memory_create` callers usually place memories.
- **`/home/<member_id>`** — a per-member private root. The input shortcut **`~`** expands to your own home, so `~/notes` means `/home/<your-id>/notes` and displays back as `~/notes`. An **agent**'s home nests under its owner's — `/home/<owner-id>/<agent-id>` — so its owner can see what the agent stores under `~` (an agent's access is capped at its owner's regardless). Service accounts do not get a home grant; put their memories under an explicitly granted path such as `/share/...`.
- **`/home/<member_id>`** — a user's private root. The input shortcut **`~`** expands to your own home, so `~/notes` means `/home/<your-id>/notes` and displays back as `~/notes`. Service accounts do not get a home grant; put their memories under an explicitly granted path such as `/share/...`.

`/` is the canonical path separator (the leading slash is optional on input). Labels must match `[A-Za-z0-9_-]`.

### Default grants

- A space **creator** gets `admin` + `owner@home` + `owner@share` — **not** `owner@root`. So the creator sees `share` and their own `~`, but not other members' homes. Because they're an admin, they can self-grant `owner@root` if they need the whole space.
- A **user** who joins a space is granted `owner@home` (their own private root). An **agent** who joins is likewise granted owner over its home — nested under its owner's (`/home/<owner-id>/<agent-id>`) — so it's usable immediately and the grant isn't clamped away. Their **shared** access comes from the group they join (the default `team` group — see below), not from a per-invite grant.
- A **user** who joins a space is granted `owner@home` (their own private root). Their **shared** access comes from the group they join (the default `team` group — see below), not from a per-invite grant.
- A **service account** starts with no tree grants and is not added to the default group. Grant it access deliberately with `me access grant <service> <path> <level>` or by adding it to an ordinary group.

### The default `team` group
Expand Down Expand Up @@ -141,7 +132,7 @@ A **space admin** owns these defaults and can change them at any time:

The provisioning defaults above are for a standard space. `me space create` flags let you shape a space's default access up front — e.g. a curated space you write to while others only read, or one where members can read without running up write (embedding) costs:

- `--no-home-grants` — joining users **and** agents get no `owner@~`. Service accounts never get `owner@~`. You (the creator) get **god mode** instead of the standard grants: `admin` + `owner@/` (the whole space).
- `--no-home-grants` — joining users get no `owner@~`. Service accounts never get `owner@~`. You (the creator) get **god mode** instead of the standard grants: `admin` + `owner@/` (the whole space).
- `--default-group <name>` — name the default/invite group (default `team`).
- `--no-default-group-grants` — create the default group **grantless** (no `read@/share` + `write@/share/projects`); you grant it by hand.
- `--no-default-group` — don't create a default group at all.
Expand Down Expand Up @@ -183,14 +174,10 @@ me group add backend bob@example.com
# Grant the group write access to a subtree (members inherit it)
me access grant backend /share/work/backend w

# Add one of your agents to the space and give it write access to share
me agent add ci-bot
me access grant ci-bot /share w

# Or create a team-owned service account for CI and grant it write access
# Create a team-owned service account for CI and grant it write access
me service create deploy-bot --admin ops@example.com
me apikey create --service deploy-bot ci-key
me access grant deploy-bot /share w
```

See [`me access`](cli/me-access.md), [`me space`](cli/me-space.md), [`me group`](cli/me-group.md), [`me agent`](cli/me-agent.md), and [`me service`](cli/me-service.md) for full command references.
See [`me access`](cli/me-access.md), [`me space`](cli/me-space.md), [`me group`](cli/me-group.md), and [`me service`](cli/me-service.md) for full command references.
Loading