openvibers/openvibe.network Commits

Add T2 WS-Z2 design for agents, delegated grants and confirmations (#8)

OP OpenVibers committed 2 Oct 2026 · c27bd3ee39 · 1 file, +589 −0

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/8

Changes

ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
nnew file mode 100644
iindex 00000000..48c9a763
---- /dev/null
++++ b/docs/t2-projects-and-grants.md
@@@ -0,0 +1,589 @@
++# T2 WS-Z2 — projects, agents and delegated grants (design)
++
++Status: **design, nothing built.** Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
++`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
++Pinned versions at time of writing: `openvibe-contracts` **v0.83.0** (`package.json:30`); every contract claim below was
++checked against the `v0.83.0` tag of OpenVibe.Contracts, and the gaps were re-checked against `v0.84.0`.
++
++Scope: an **agent** is a principal (`agt_<ULID>`) that acts for one person, inside one developer project, run by one
++host (a developer app or a first-party service such as OpenVibe.Actor). Its owner **delegates** part of the host's
++authority to it, capability by capability, each in a **mode**. A **sensitive** capability always needs the owner's
++**confirmation**, unless a standing rule the owner approved covers it. An owner can cap each capability with a
++**budget**. Network stores and decides all of this; the owning services enforce it at their boundary, as they already do
++for quotas (`docs/developer-projects.md`, "Quotas"). Nothing about apps, service principals, the grantability rule or the
++allowances changes.
++
++## 1. What exists (origin/main `1ea05ff`)
++
++| Piece | Where | State |
++|---|---|---|
++| Projects, members, roles, apps, app grants, quotas, audit | `migrations/0001_initial.sql:436-530`, `server/developer/store.js`, `/api/v1/projects` (`server/index.js:592`) | built; the ownership boundary already used by node principals (`0008`) |
++| App grant ceiling | `store.js:518` `withinAllowance`, `:550` `decideGrant` (`403 grant.beyond_allowance`), `:187` `setAllowance` (shrink revokes), `server/developer/tokens.js:48` `effectiveGrants` (issuance intersects again) | built |
++| Grantability | `server/developer/policy.js:16-30` (`public` and `partner`, `active` only), `:97` `allowanceFor` (sandbox allowance for sandbox apps) | built |
++| Service-principal grants | `principal_grants` + `principal_grant_changes`, `server/identity/grants-admin.js` (owner-only, reason, expiry, `network.principal_grant.changed`), `server/identity/principals.js:287` `grantsFor` | built |
++| Subjects | `server/identity/subjects.js:95,103` resolve `usr_` and `gst_` only | no `agt_` |
++| Capability guard | `principals.js:344` `guard(capability)` throws at boot on an id the catalog does not have | built |
++| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.83.0 as `public`, `planned`) | proposed, unused |
++| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |
++| Sensitive capabilities, confirmations, budgets, agents | — | missing |
++
++## 2. Contract vocabulary (v0.83.0, used verbatim)
++
++- **`identity.subject-ref@1`** has the agent form `{ "type": "agent", "id": "agt_<ULID>" }` ("an agent (OpenVibe.Actor, or
++ a developer app's agent) acting under grants its owner delegated"). `ids.PREFIX` has `agent: 'agt'` and
++ `confirmation: 'cnf'`; `ids.principalSub({type:'agent', id})` returns `agent:agt_…`.
++- **`events.event-envelope@1`** has an optional `on_behalf_of` (a SubjectRef): the person an agent acted for.
++- **`capabilities.capability@1`** has an optional boolean **`sensitive`**: "the action has an external side effect (money,
++ sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless
++ a standing rule covers it". 23 capabilities carry `sensitive: true` at v0.83.0. Two of them are grantable to apps
++ today (`public` + `active`): `media.object.delete` (in the default sandbox allowance) and `space.post.write`. The other
++ sensitive ones are `first-party` (e.g. `chat.message.send`, `blog.post.publish`, `tips.superchat.create`), `internal`
++ (`billing.*`, `network.coins.transfer`) or `public` but `planned`/`deprecated`.
++- **`network.confirmation-request@1`** (`additionalProperties: false`): required `id` (`^cnf_<ULID>$`), `owner`,
++ `requested_by` (SubjectRefs), `capability`, `summary` (≤ 500), `state` (`pending | approved | denied | expired |
++ cancelled`), `expires_at`, `created_at`; optional `details` (object), `resources` (`common.entity-ref@1[]`),
++ `standing_rule` (`once | session | until | always`), `decided_at`.
++- The capability `visibility` enum at v0.83.0 is **`public | partner | first-party | internal`**. `docs/developer-projects.md`
++ ("`partner` is not in the enum yet") and the comment at `policy.js:8` predate that; slice 1 corrects both. The rule
++ itself does not change.
++
++**Gaps — not in v0.83.0 nor v0.84.0, so the slices that need them are blocked (section 8):**
++
++1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` and `actor_type` only `service | app |
++ mod`. An agent token cannot validate. (Same shape of gap as the node actor in `docs/t2-resource-registry.md` §9.)
++2. No capability for a service to create or consume a confirmation (`network.*` at v0.83.0 has none; inventing one makes
++ `principals.guard` throw at boot).
++3. No event payload for a confirmation decision or an agent/delegated-grant change, so these changes are audit rows
++ without `event` (the export-token precedent: "The row is not a platform event").
++4. No notification type for "an agent asks you" in `openvibe-shared/notifications` `TYPES`.
++
++## 3. Data model
++
++Migrations `0010`–`0013` (`0008` is the highest on main; `0009` is reserved for registry slice 5). Header convention of
++`0007`/`0008`: `-- phase: expand`, `-- plan T2 WS-Z2. <why>`, then DDL with `IF NOT EXISTS`. The runtime creates nothing
++(`ensureSchema()` stays a no-op with a comment).
++
++**Stores.** Network runs on PostgreSQL in production and on embedded PGlite in development and in `npm test`;
++`npm run test:pg` runs the same suite on real PostgreSQL. The migration runner applies the files below, in the dialect of
++`0007`/`0008`: `text COLLATE "C"` (compares like the old SQLite text), timestamps as ISO `text` (`ov_now_iso()`), `~`
++regex CHECKs, JSON as `text` parsed in JS (never `jsonb`, as `platform_nodes.doc`), `bigint GENERATED ALWAYS AS
++IDENTITY`, partial indexes, no triggers.
++
++**SQLite.** The serving SQLite store is retired (ADR-035; `server/db/database.js`), but SQLite is still read by
++`scripts/migrate-to-postgres.js` and `scripts/pg-preflight-collisions.js`, and the schema must stay expressible there.
++Every table below therefore has a **SQLite form**, written mechanically from the PostgreSQL file and kept next to the
++test that checks it (`test/fixtures/sqlite/00NN_*.sql`, section 7). Nothing at runtime applies it. The rules:
++
++| PostgreSQL (`migrations/00NN_*.sql`) | SQLite form |
++|---|---|
++| `text COLLATE "C"` | `text` (SQLite's default `BINARY` collation compares like `"C"`) |
++| `DEFAULT ov_now_iso()` | `DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` (the same `…T07:53:27.720Z` shape) |
++| `bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` / `bigint` | `INTEGER PRIMARY KEY AUTOINCREMENT` / `INTEGER` |
++| `c ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'` (also `usr_`, `cnf_`) | `substr(c, 1, 4) = 'agt_' AND length(c) = 30 AND substr(c, 5) NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*'` |
++| `audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'` | `length(audience) BETWEEN 10 AND 73 AND substr(audience, 1, 9) = 'openvibe.' AND substr(audience, 10, 1) GLOB '[a-z]' AND substr(audience, 11) NOT GLOB '*[^a-z0-9-]*'` |
++| `session_id ~ '^[A-Za-z0-9._:-]{8,128}$'` | `length(session_id) BETWEEN 8 AND 128 AND session_id NOT GLOB '*[^A-Za-z0-9._:-]*'` |
++| `request_digest ~ '^[0-9a-f]{64}$'` | `length(request_digest) = 64 AND request_digest NOT GLOB '*[^0-9a-f]*'` |
++| composite and partial indexes, `IF NOT EXISTS`, multi-column `FOREIGN KEY` | unchanged (SQLite enforces the foreign keys with `PRAGMA foreign_keys = ON`) |
++
++Both forms were applied while writing this doc (PGlite, and `node:sqlite` in memory, each twice to prove `IF NOT
++EXISTS`) and agreed on 17 accept/reject cases: wrong prefix or a non-Crockford character, a cross-project or
++cross-environment app host, a service host in sandbox, `revoked` without `revoked_at`, a bad audience, session id or
++digest, `always` with `until_at`, `used_at` on a pending confirmation, a negative budget, and the ISO default.
++
++### 0010 — `0010_agents.sql`
++
++```sql
++CREATE UNIQUE INDEX IF NOT EXISTS dev_apps_project_env_key ON dev_apps (id, project_id, environment);
++
++CREATE TABLE IF NOT EXISTS dev_agents (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^agt_[0-9A-HJKMNP-TV-Z]{26}$'),
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ host_kind text COLLATE "C" NOT NULL CHECK (host_kind IN ('app', 'service')),
++ host_app_id text COLLATE "C",
++ host_service text COLLATE "C" REFERENCES oauth_clients(client_id),
++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
++ name text COLLATE "C" NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'revoked')),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ created_by text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ FOREIGN KEY (host_app_id, project_id, environment) REFERENCES dev_apps (id, project_id, environment),
++ CHECK ((host_kind = 'app') = (host_app_id IS NOT NULL)),
++ CHECK ((host_kind = 'service') = (host_service IS NOT NULL)),
++ CHECK (host_kind = 'app' OR environment = 'production'),
++ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS dev_agents_project_idx ON dev_agents (project_id, status);
++CREATE INDEX IF NOT EXISTS dev_agents_owner_idx ON dev_agents (owner_subject, status);
++CREATE INDEX IF NOT EXISTS dev_agents_app_idx ON dev_agents (host_app_id) WHERE host_app_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS dev_agents_service_idx ON dev_agents (host_service) WHERE host_service IS NOT NULL;
++```
++
++- `owner_subject` is the person the agent acts for (`on_behalf_of`), always a `usr_` — never a guest, an app or another
++ agent (no agent owns an agent). `created_by` is the same person (section 5).
++- **An app host belongs to the agent's project, in the agent's environment.** The composite foreign key makes a row
++ whose `host_app_id` is an app of another project (or of the other environment) impossible; `dev_apps.id` is already
++ the primary key, so the new unique index only gives the key a target and costs one index. With `MATCH SIMPLE` a
++ service host (`host_app_id IS NULL`) is not checked against `dev_apps`. The routes check it first anyway (an app of
++ another project is `404 app.not_found`, section 4), and token issuance checks it again (section 4, "Agent tokens").
++- An app-hosted agent copies its app's `environment` and never changes it (apps never change environment either). A
++ service-hosted agent is `production`: the code that runs it is first-party, not the project's.
++- `revoked` is final, like an app. `paused` is the owner's kill switch and is reversible.
++
++### 0011 — `0011_agent_grants.sql`
++
++```sql
++CREATE TABLE IF NOT EXISTS dev_agent_grants (
++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL CHECK (audience ~ '^openvibe\.[a-z][a-z0-9-]{0,63}$'),
++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('auto', 'confirm')),
++ status text COLLATE "C" NOT NULL CHECK (status IN ('active', 'revoked')),
++ granted_at text COLLATE "C" NOT NULL,
++ granted_by text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C",
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ revoke_reason text COLLATE "C",
++ PRIMARY KEY (agent_id, capability),
++ CHECK ((status = 'revoked') = (revoked_at IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS dev_agent_grants_active_idx ON dev_agent_grants (agent_id) WHERE status = 'active';
++CREATE INDEX IF NOT EXISTS dev_agent_grants_cap_idx ON dev_agent_grants (capability, status);
++```
++
++`sensitive` is **not a column**: it is read from the installed catalog every time (`capabilities.get(id).sensitive`), so a
++capability Contracts marks sensitive later is treated as sensitive on the next token with no data migration. The stored
++`mode` is what the owner chose; `effective_mode` (section 5) is computed.
++
++### 0012 — `0012_confirmations.sql`
++
++```sql
++CREATE TABLE IF NOT EXISTS dev_standing_rules (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
++ capability text COLLATE "C" NOT NULL,
++ rule text COLLATE "C" NOT NULL CHECK (rule IN ('session', 'until', 'always')),
++ session_id text COLLATE "C" CHECK (session_id ~ '^[A-Za-z0-9._:-]{8,128}$'),
++ until_at text COLLATE "C",
++ source text COLLATE "C" NOT NULL CHECK (source ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ CHECK ((rule = 'session') = (session_id IS NOT NULL)),
++ CHECK ((rule = 'always') = (until_at IS NULL))
++);
++CREATE INDEX IF NOT EXISTS dev_standing_rules_live_idx ON dev_standing_rules (agent_id, capability) WHERE revoked_at IS NULL;
++
++CREATE TABLE IF NOT EXISTS dev_confirmations (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^cnf_[0-9A-HJKMNP-TV-Z]{26}$'),
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id), -- requested_by {type: agent}
++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'), -- owner {type: user}
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL, -- the service that created it
++ summary text COLLATE "C" NOT NULL CHECK (length(summary) BETWEEN 1 AND 500),
++ details text COLLATE "C" NOT NULL DEFAULT '{}',
++ resources text COLLATE "C" NOT NULL DEFAULT '[]',
++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'denied', 'expired', 'cancelled')),
++ standing_rule text COLLATE "C" CHECK (standing_rule IN ('once', 'session', 'until', 'always')),
++ session_id text COLLATE "C",
++ request_digest text COLLATE "C" NOT NULL CHECK (request_digest ~ '^[0-9a-f]{64}$'),
++ rule_id bigint REFERENCES dev_standing_rules(id),
++ expires_at text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ decided_at text COLLATE "C",
++ decided_by text COLLATE "C",
++ used_at text COLLATE "C",
++ cancel_reason text COLLATE "C",
++ CHECK (state <> 'pending' OR decided_at IS NULL),
++ CHECK (state NOT IN ('approved', 'denied') OR decided_at IS NOT NULL),
++ CHECK (used_at IS NULL OR state = 'approved')
++);
++CREATE INDEX IF NOT EXISTS dev_confirmations_inbox_idx ON dev_confirmations (owner_subject, state, created_at);
++CREATE INDEX IF NOT EXISTS dev_confirmations_due_idx ON dev_confirmations (expires_at) WHERE state = 'pending';
++CREATE INDEX IF NOT EXISTS dev_confirmations_agent_idx ON dev_confirmations (agent_id, state);
++CREATE INDEX IF NOT EXISTS dev_confirmations_spendable_idx ON dev_confirmations (agent_id, capability) WHERE state = 'approved' AND used_at IS NULL;
++```
++
++- Columns map 1:1 to the contract fields; `owner`/`requested_by` are rebuilt as SubjectRefs from `owner_subject` /
++ `agent_id`, `details`/`resources` are `JSON.parse`d. `audience`, `session_id`, `request_digest`, `rule_id`, `used_at`,
++ `decided_by`, `cancel_reason` and `project_id` are Network-local and never appear inside the contract document
++ (`additionalProperties: false`).
++- `standing_rule` is the rule the owner chose when approving (`once` when none); `rule_id` is the rule that approved it
++ automatically, if any (`decided_by = 'rule:<id>'`).
++- `request_digest` is the SHA-256 of the action the owning service will perform (method, route, canonical body). The
++ approval is for **that** action; consuming with any other digest fails.
++- `cancel_reason` says why a row was cancelled (`service`, `agent_revoked`, `agent_paused`… section 5, step 6). An
++ `approved`, unused row may be cancelled too (its `decided_at` stays); the spendable index finds those rows for the
++ cascades.
++
++### 0013 — `0013_agent_budgets.sql`
++
++```sql
++CREATE TABLE IF NOT EXISTS dev_agent_budgets (
++ agent_id text COLLATE "C" NOT NULL REFERENCES dev_agents(id),
++ capability text COLLATE "C" NOT NULL,
++ limit_value bigint NOT NULL CHECK (limit_value >= 0),
++ budget_window text COLLATE "C" NOT NULL CHECK (budget_window IN ('minute', 'hour', 'day', 'month', 'total')),
++ unit text COLLATE "C" NOT NULL DEFAULT 'requests',
++ updated_at text COLLATE "C" NOT NULL,
++ updated_by text COLLATE "C" NOT NULL,
++ PRIMARY KEY (agent_id, capability)
++);
++```
++
++The same shape as `dev_quotas` (`limit_value`, window set, `unit`), one budget per (agent, capability), so a budget and the
++project's quota for the same capability compare field by field.
++
++## 4. Routes
++
++Public routes follow the projects API exactly (`docs/developer-projects.md`, "API"): a Network **user** access token in
++`Authorization: Bearer`, no cookies, service/app/agent tokens get `401`, `problem+json` with a stable `code`,
++`X-OpenVibe-Request-Id` and `traceparent`, `Cache-Control: private, no-store`, 60 requests per minute. Non-members get
++`404 project.not_found`; a confirmation that is not yours is `404 confirmation.not_found`.
++
++### Agents and their grants — under `/api/v1/projects/:project`
++
++| Method and path | Body → result |
++|---|---|
++| `GET /agents[?owner=me]` | `{ agents: [agent] }` |
++| `POST /agents` | `{ name, host: {type:'app', id:'app_…'} \| {type:'service', id:'actor'} }` → `201 agent`; an app host must be a non-revoked app **of `:project`** (otherwise `404 app.not_found`, the same answer as an unknown id, so other projects' app ids are not probed) |
++| `GET /agents/:agent` | `agent` with `grants`, `budgets`, `rules` |
++| `PATCH /agents/:agent` | `{ name }` → `agent` |
++| `POST /agents/:agent/pause`, `/resume` | → `agent` |
++| `DELETE /agents/:agent` | revoke (final) → `agent` |
++| `GET /agents/:agent/grants` | `{ grants: [grant] }` |
++| `PUT /agents/:agent/grants/:capability` | `{ mode: 'auto'\|'confirm', expires_at? }` → `grant` |
++| `DELETE /agents/:agent/grants/:capability` | revoke → `grant` |
++| `GET /agents/:agent/budgets` | `{ budgets: [budget] }` |
++| `PUT /agents/:agent/budgets/:capability` / `DELETE` | `{ limit, window, unit }` → `budget` |
++| `GET /agents/:agent/rules` | `{ rules: [rule] }` |
++| `DELETE /agents/:agent/rules/:rule` | revoke a standing rule → `rule` |
++
++```jsonc
++// agent
++{ "id": "agt_…", "subject": { "type": "agent", "id": "agt_…" }, "project_id": "prj_…",
++ "owner": { "type": "user", "id": "usr_…" }, "host": { "type": "app", "id": "app_…" },
++ "environment": "sandbox", "name": "Release bot", "status": "active",
++ "created_at": "…", "updated_at": "…", "revoked_at": null }
++// grant
++{ "capability": "media.object.delete", "audience": "openvibe.media", "mode": "auto", "effective_mode": "confirm",
++ "sensitive": true, "status": "active", "within_host": true, "expires_at": null, "granted_at": "…", "granted_by": "user:usr_…" }
++// budget
++{ "capability": "media.object.upload", "limit": 1073741824, "window": "day", "unit": "bytes", "enforced_by": "openvibe.media" }
++// rule
++{ "id": 7, "capability": "chat.message.send", "rule": "until", "until_at": "…", "session_id": null, "source": "cnf_…", "created_at": "…" }
++```
++
++`host` and `owner` are SubjectRefs (`{type:'service', id:'actor'}` is the contract's PrincipalSubject form).
++`within_host: false` marks an active grant the ceiling no longer covers (it is left out of tokens until it is covered
++again or revoked by the cascade, section 6).
++
++### The owner's confirmation inbox — `/api/v1/confirmations`
++
++| Method and path | Body → result |
++|---|---|
++| `GET /?state=pending&before=&limit=` | `{ confirmations: [<network.confirmation-request@1>], agents: { "agt_…": { name, project_id, host } }, next_before }` |
++| `GET /:id` | `{ confirmation: <network.confirmation-request@1>, agent: {…} }` |
++| `POST /:id/approve` | `{ standing_rule?: 'once'\|'session'\|'until'\|'always', until?: <date-time> }` → `{ confirmation, rule? }` |
++| `POST /:id/deny` | `{}` → `{ confirmation }` |
++
++Elements of `confirmations` are the contract documents verbatim, validated in tests; context lives beside them, never
++inside (the `docs/t2-resource-registry.md` §4 rule).
++
++### Internal — service tokens, loopback only (`docs/shared-contracts.md` §11)
++
++| Method and path | Capability | Body → result |
++|---|---|---|
++| `GET /internal/agents/:agent` | `network.project.read` (exists, first-party) | `{ agent, grants, budgets }`, grants and budgets filtered to the caller's own audience (`openvibe.<caller>`) |
++| `POST /internal/confirmations` | **`network.confirmation.manage`** (proposed, slice 1) | `{ requested_by: {type:'agent', id}, capability, summary, details?, resources?, request_digest, session_id?, ttl_s? }` → `201 { confirmation }` (pending) or `200 { confirmation }` (approved by a standing rule) |
++| `GET /internal/confirmations/:id` | same | `{ confirmation, used_at }` |
++| `POST /internal/confirmations/:id/consume` | same | `{ request_digest }` → `200 { confirmation, used_at }` |
++| `POST /internal/confirmations/:id/cancel` | same | → `{ confirmation }` |
++
++`ttl_s` is 60–86400, default 900. All `/internal/confirmations` routes answer only the audience that created the
++confirmation (others: `404`).
++
++### Agent tokens — `POST /oauth/token`
++
++The **host** authenticates as itself, exactly as today (an app with its `client_secret` through the developer path, a
++service through `principals.issueToken`), and adds `agent=agt_…` plus `audience` and an optional `scope`.
++
++**Host binding, checked on every issuance** (`400 invalid_grant` for all of them, one message, so a host cannot learn
++whether someone else's agent exists): the authenticated client **is** the agent's host (`app.id = agent.host_app_id`,
++or `client_id = agent.host_service`); for an app host, `app.project_id = agent.project_id`, `app.environment =
++agent.environment` and the app is not revoked (the foreign key of `0010` already makes the first two true for every
++stored row; issuance re-reads them so a future change to that key cannot widen tokens); the project is not archived;
++the agent is `active`; the owner is still a member and not banned or deleted.
++
++| Claim | Value |
++|---|---|
++| `sub`, `actor_type` | `agent:agt_<ULID>` (`ids.principalSub`), `agent` |
++| `aud` | `[audience]` |
++| `cap` | active grants with `effective_mode = 'auto'` ∩ ceiling ∩ budgets not set to 0 |
++| `cap_confirm` | active grants with `effective_mode = 'confirm'` ∩ ceiling ∩ budgets not set to 0 |
++| `on_behalf_of`, `project_id`, `env` | the owner `usr_`, the project, the agent's environment |
++| `ns` | **app host:** `[project_id, app.<project_id>.*]`, as the host app's own token. **Service host:** `projectNamespaces(project_id)` (`tokens.js:56`) ∩ the namespaces of the host's `principal_grants` rows for the capabilities in `cap` ∪ `cap_confirm` (`grantsFor(host, audience)`, the same rows the host's own token takes `ns` from): a project namespace is kept only if one of those rows names it, or names a `*` pattern that matches it under the contracts' namespace matcher (the one `principals.guard({ namespace })` uses). A grant with `namespaces = []` contributes none; a host whose grants cover no namespace of the project gives `ns: []`, and the receiver refuses every namespaced action |
++| `act` | `{ sub: 'app:app_…' \| 'svc:actor' }`, the host (RFC 8693 actor claim) |
++| `iat`, `exp`, `jti` | 300 seconds, no refresh |
++
++An agent token never carries a namespace its host's own token for that audience would not carry: the ceiling covers
++namespaces as well as capabilities.
++
++**Fail closed by construction:** a confirm-mode capability is never in `cap`. A receiver that does not know agents sees
++only `cap`, so it can never perform a sensitive action without a confirmation; it simply refuses it.
++
++## 5. Auth rules
++
++### Who may do what
++
++| Action | Who |
++|---|---|
++| Create an agent hosted by a **sandbox app** | a project member with role developer+ (who could create that app), for **themselves** only |
++| Create an agent hosted by a **production app** | admin+ of the project, for themselves only |
++| Create an agent hosted by a **service** | developer+ of the project, for themselves; the service must be in `AGENT_HOST_SERVICES` (default `actor`) and be an `oauth_clients` row |
++| Read agents, grants, budgets | any member (viewer+); staff, any project |
++| Rename | the agent's owner, admin+ |
++| Pause | the agent's owner, admin+, staff |
++| Resume | the agent's owner, admin+ (never staff: a staff pause is lifted by the owner) |
++| Revoke the agent | the agent's owner, admin+, staff |
++| **Set a delegated grant (PUT)** | **the agent's owner only** — a person delegates their own authority; an admin cannot delegate for another member, staff never |
++| Revoke a delegated grant | the agent's owner, admin+, staff |
++| Set or remove a budget | the agent's owner, admin+ (an admin may cap a member's agent) |
++| Revoke a standing rule | the agent's owner, admin+, staff |
++| Read, approve or deny a confirmation | **its `owner` only** (not admins, not staff; staff see the audit row) |
++| Create, read, consume, cancel a confirmation | the owning service of the capability (`audience = openvibe.<capability owner>`), with `network.confirmation.manage` |
++| Mint an agent token | the agent's host, authenticated as itself |
++
++There is no route that creates an agent for another person, and no agent can create an agent, set a grant or approve a
++confirmation: agent, app and service tokens are not users on the public API (`401`).
++
++### The delegation ceiling (the agent analogue of "a grant never exceeds the allowance")
++
++An agent never holds more than its host may do *for that project*:
++
++- **App host:** the capability must be in `effectiveGrants(host app, audience)` (`tokens.js:48`): the app's approved
++ grant ∩ the project allowance (∪ the sandbox allowance for a sandbox app) ∩ grantable now. The grantability rule and
++ both allowance rules therefore apply to agents **unchanged and without a second implementation**: no new path reaches a
++ `first-party` or `internal` capability.
++- **Service host:** the capability must be in the host's current `principals.grantsFor(host, audience)` (unexpired
++ `principal_grants`) **and** its visibility must be `public`, `partner` or `first-party` — never `internal`
++ (`billing.*`, `network.coins.*` stay service-to-service). This is how Actor's agents may e.g. send a chat message as
++ their owner (`chat.message.send`, first-party, sensitive → always confirmed). The token's namespaces are bounded the
++ same way, by the namespaces of those `principal_grants` rows (section 4, `ns`).
++- **Everyone:** the capability exists in the installed catalog and is `active` (`grant.unknown_capability`,
++ `grant.not_grantable`), and the agent is not `revoked`.
++
++It is checked in three places, as the allowance is: `PUT …/grants/:capability` refuses `403 grant.beyond_host`; a change
++that shrinks the ceiling revokes the delegated grants outside it (section 6); token issuance intersects again.
++
++### Modes
++
++| Mode | Meaning | Allowed for |
++|---|---|---|
++| `auto` | the agent may use the capability with its token alone | non-sensitive capabilities only; `PUT` with `auto` on a sensitive one is `422 grant.sensitive_requires_confirm` |
++| `confirm` | every use needs an approved confirmation request (or a standing rule) | any capability; the default when `mode` is omitted |
++
++`effective_mode = sensitive ? 'confirm' : mode`. A capability Contracts marks sensitive after a grant was set to `auto`
++becomes `confirm` on the next token; the stored row is not rewritten. Expiry (`expires_at`) ends a grant at that instant
++(issuance filters it; a sweep records `grant.expired` once, as `grants-admin.js` `expireDue` does).
++
++### Sensitive capabilities and the confirmation lifecycle
++
++The owning service is the one that knows what the action really is, so it — not the agent — writes the `summary` and
++the digest. An agent cannot describe its own request to its owner.
++
++1. **Ask.** The agent calls the owning service with a token whose `cap_confirm` has the capability. Without a usable
++ `OpenVibe-Confirmation: cnf_…` header the service answers `403 confirmation.required` after calling
++ `POST /internal/confirmations` (owner derived by Network from the agent, never from the body), and returns the
++ `cnf_` id to the agent. Network refuses `409 confirmation.agent_inactive` (agent paused/revoked or owner banned or
++ deleted), `403 grant.not_delegated` (no active grant, or the grant's effective mode is `auto` — nothing to confirm),
++ `403 confirmation.wrong_audience` (the capability is not the caller's), `429 confirmation.too_many_pending` (20 pending
++ per agent).
++2. **Standing rule shortcut.** If a live rule covers (agent, capability) — `always`; `until` with `until_at` in the
++ future; `session` with the same `session_id` and `until_at` in the future — the confirmation is created already
++ `approved` (`decided_by = 'rule:<id>'`, `rule_id` set, `200`). Every sensitive use still leaves a row.
++3. **Decide.** The owner sees it in the inbox (and, once slice 9 lands, a notification). `approve` / `deny` move
++ `pending` → `approved` / `denied` and set `decided_at`. Anything else is `409 confirmation.not_pending`; a pending row
++ past `expires_at` is `409 confirmation.expired` (and is marked expired). Approving with `session`, `until` (`until`
++ required, at most 30 days) or `always` also inserts a `dev_standing_rules` row; `session` lasts at most 24 hours and
++ needs the confirmation's `session_id` (`422 confirmation.no_session` otherwise). Denying never creates a rule.
++4. **Use once.** The agent retries with the header. The service calls `consume` with its digest of *this* request. An
++ approval is only as good as the authority behind it **now**, so consume re-checks everything that let it be created,
++ in one transaction (`db.tx`) that first locks the agent row (`SELECT … FROM dev_agents WHERE id = ? FOR UPDATE` on
++ PostgreSQL; SQLite serialises writers anyway) so a revocation cannot interleave:
++ - the agent is `active`, its project is not archived, and its owner is still a member, not banned and not deleted;
++ - for an app host, the app is not revoked and still belongs to the agent's project and environment;
++ - the delegated grant (agent, capability) is `active`, unexpired, and its `effective_mode` is still `confirm`;
++ - the capability is still inside the ceiling (`effectiveGrants(host app, audience)` or `grantsFor(host service,
++ audience)` minus `internal`), and its budget is not `0`.
++
++ Then one statement spends it: `UPDATE dev_confirmations SET used_at = ? WHERE id = ? AND audience = ? AND state =
++ 'approved' AND used_at IS NULL AND expires_at > ? AND request_digest = ?` — one row → `200`. Otherwise `409
++ confirmation.used`, `confirmation.mismatch`, `confirmation.expired`, `confirmation.cancelled`,
++ `confirmation.agent_inactive` (agent, project, app or owner) or `403 grant.not_delegated` (grant gone or outside the
++ ceiling). Pausing an agent or revoking its grant therefore stops its sensitive actions **at once**, not after the
++ 5-minute token lifetime, even for an approval given before the change.
++5. **Expire.** A sweep every minute moves `pending` rows past `expires_at` to `expired`; reads already report them as
++ expired before the sweep. An approved but unused confirmation simply stops being consumable at `expires_at`.
++6. **Cancel.** The creating service may cancel a pending one (the agent's run ended; `cancel_reason = 'service'`).
++ Every change that takes authority away cancels, **in its own transaction**, both the `pending` and the `approved`
++ but unused (`used_at IS NULL`) confirmations it affects, and revokes the matching standing rules: pausing or revoking
++ the agent (all of the agent's), revoking or expiring a delegated grant (that capability's), a ceiling cascade
++ (section 6: the capabilities it revokes), the owner leaving the project or being erased, the app being revoked and the
++ project being archived (all of the agents'). `cancel_reason` names the cause (`agent_paused`, `agent_revoked`,
++ `grant_revoked`, `grant_expired`, `beyond_host`, `member_removed`, `account_erased`, `app_revoked`,
++ `project_archived`). Resuming a paused agent revives nothing: its next sensitive action asks again. The consume
++ re-check of step 4 stays as the second line, for authority that lapses without a Network write (a `principal_grants`
++ or delegated-grant `expires_at` passing before its sweep, a capability leaving the catalog on upgrade).
++
++Every transition writes a `dev_audit` row (`confirmation.created|approved|denied|expired|cancelled|used`, project id,
++agent id, capability; never `summary` or `details`, which may hold message text).
++
++### Budgets: who checks what
++
++| Check | Who | When |
++|---|---|---|
++| A budget is within the project's quota (same capability, window and unit) | Network | `PUT …/budgets/:capability` → `422 budget.beyond_quota` |
++| A budget only exists for a capability the agent holds | Network | `PUT` → `404 grant.not_found`; revoking the grant deletes its budget |
++| Usage against the budget | **the owning service**, before the side effect, with its own counters | per action → `429 budget.exceeded` (AI's `quota.exceeded` precedent) |
++| A budget of `0` | Network | issuance leaves the capability out of `cap` and `cap_confirm` (a per-capability off switch that needs no metering) |
++| Money across services (Vibes / promo) | Billing's universal `govern` (plan T5) | recorded only until T5 |
++
++Network never meters. Its usage rollups (`dev_usage_*`) are per project and "never name who did the work", so they
++cannot measure an agent; reading them for budgets would also lag by an hour. This keeps the rule of
++`docs/developer-projects.md` ("Quotas are enforced by the owning services, not here") true for budgets: `enforced_by` is
++the capability's audience. Owning services read budgets from `GET /internal/agents/:agent`.
++
++## 6. How it fits what exists — nothing breaks
++
++- **App grants (`dev_grants`, `store.js`).** Untouched: same table, statuses, roles, approval, `grant.beyond_allowance`,
++ `network.grant.changed`. Three existing transactions gain one cascade each, **after** their current work and in the
++ same transaction: `setAllowance` (`:187`) and `decideGrant`/the grant revoke (`:550`) revoke the delegated grants of
++ the app's agents that fall outside `effectiveGrants`; `revokeAppTx` (`:397`) and `archiveProject` (`:176`) revoke the
++ agents themselves (`revoked_by = 'app_revoked'` / `'project_archived'`). `removeMember` (`:281`) revokes the leaving
++ person's agents in that project. Each of these cascades also cancels the affected pending and approved-unused
++ confirmations and revokes the standing rules (section 5, step 6). A sandbox agent's grant still inside the sandbox allowance survives a staff change,
++ for the same reason the app's grant does.
++- **App tokens (`tokens.js`).** Unchanged byte for byte when no `agent` parameter is sent; the new branch is taken only
++ for `agent=agt_…`. `effectiveGrants` is reused, not copied.
++- **Service-principal grants (`principal_grants`, `grants-admin.js`).** Untouched; agents never get `principal_grants`
++ rows. `POST /api/admin/grants/revoke` and the expiry sweep gain the cascade for agents hosted by that service.
++ `DEFAULT_GRANTS` are not extended.
++- **The grantability rule and the allowances.** Not reimplemented: the ceiling *is* `effectiveGrants` for app hosts. The
++ one new rule (service-hosted agents never get `internal`) only narrows.
++- **Sensitive app grants.** `media.object.delete` and `space.post.write` keep working for apps exactly as today:
++ `capability@1` defines confirmation for **agents**. Apps acting with `on_behalf_of` are listed as unresolved.
++- **Mod principals, node principals, export tokens.** Untouched.
++- **Account deletion (`account-data.js:315` `erase`)** revokes the subject's agents, cancels their pending and approved-unused
++ confirmations and revokes their rules; the account export (`:160` `networkPart`) lists the person's agents, grants, rules and
++ confirmations (without other people's data).
++- **Migration of existing rows.** None needed: the four tables start empty, no existing column changes, no backfill,
++ `0010`–`0013` are `expand` only. Rolling back a slice means its routes disappear; its empty or orphaned rows are inert
++ because issuance and the routes are the only readers.
++
++## 7. Test plan
++
++New files (the `test/nodes.test.js` / `test/developer-projects.test.js` setup: temp DB via `getDb()`, a generated RSA
++keypair, a bare express app, user tokens, service tokens through `POST /oauth/token`):
++
++- `test/agents.test.js` — roles matrix for create/rename/pause/resume/revoke (viewer refused, developer only for sandbox
++ hosts, admin for production, staff may pause/revoke but not create or resume); creating for another person is
++ impossible; `AGENT_HOST_SERVICES` refuses an unlisted service; CHECK constraints reject a bad row (app host without
++ `host_app_id`, service host in sandbox, revoked without `revoked_at`); **an app of another project as host**: the route
++ answers `404 app.not_found`, and a direct insert of such a row (or of an app of the other environment) fails on the
++ composite foreign key; cascades: revoke app, archive project, remove member, account erase.
++- `test/agent-schema.test.js` — applies `0010`–`0013` and their SQLite forms (`test/fixtures/sqlite/00NN_*.sql`, to a
++ `node:sqlite` in-memory database with `PRAGMA foreign_keys = ON`; `node:sqlite` because the native `better-sqlite3`
++ build need not match the running Node), each twice, and runs one table of accept/reject rows against both, asserting
++ the same outcome (the 17 cases of section 3, grown with each slice). A drift between the two forms fails here.
++- `test/agent-grants.test.js` — ceiling: an app-hosted agent cannot get a capability the app lacks, outside the
++ allowance, or `first-party`; a sandbox agent gets a sandbox-allowance capability; a service-hosted agent never gets an
++ `internal` one; `auto` on `media.object.delete` → `422`; `effective_mode` follows the catalog; only the owner may PUT;
++ cascades from `setAllowance` shrink, app grant revoke and `/api/admin/grants/revoke`, each with its audit row.
++- `test/agent-budgets.test.js` — `budget.beyond_quota`; budget needs a grant; grant revoke removes it; read via
++ `/internal/agents/:agent` filtered to the caller's audience; auth matrix (no token, retired shared key, wrong
++ capability, sandbox token).
++- `test/confirmations.test.js` — every view validates as `network.confirmation-request@1`; create → approve → consume
++ once (second consume `409 confirmation.used`); digest mismatch; deny; approve after expiry; lazy expiry and the sweep;
++ cancel; standing rules `once`/`session`/`until`/`always` and their revocation; owner B and staff get `404`; 20-pending
++ limit; wrong audience; audit rows contain no `summary`/`details`. **Revoke after approval**, one case per cause:
++ approve, then pause the agent / revoke it / revoke the delegated grant / shrink the allowance (app host) / revoke the
++ host's `principal_grants` row (service host) / remove the owner from the project / revoke the app / archive the project
++ → the row is `cancelled` with that `cancel_reason`, consume answers `409 confirmation.cancelled`, and resuming the agent
++ does not make it spendable again. **Lapse without a write**: approve, then let the delegated grant's `expires_at` (and,
++ separately, the host `principal_grants` row's) pass with the sweep stopped → consume answers `403 grant.not_delegated`
++ and `used_at` stays `NULL`. Two concurrent consumes of one approval: exactly one `200`.
++- `test/agent-tokens.test.js` — claims match the proposed `identity.service-token-claims@1`; `cap` holds only auto
++ grants and `cap_confirm` only confirm ones; a revoked/paused agent, a banned owner, an archived project, a budget of 0
++ and a host mismatch each refuse or drop as specified; **cross-project host**: app B (project B) asking for a token for
++ an agent of project A gets `400 invalid_grant`, as does app A' of project A that is not the agent's host, and a row
++ forced past the foreign key in the test (constraint dropped) is still refused at issuance; **service-host namespaces**:
++ with the host's `principal_grants` row restricted to one project namespace, the agent token's `ns` holds only that one;
++ with `namespaces = []` it is `[]`; an app-hosted agent's `ns` equals its app's; **app tokens without `agent` are identical to before** (snapshot
++ of the claim keys and values except `jti`/`iat`/`exp`).
++
++Regression runs each slice: `test/developer-projects.test.js`, `test/developer-defaults.test.js`,
++`test/developer-export-tokens.test.js`, `test/grants-admin.test.js`, `test/principals.test.js`,
++`test/service-principal.test.js`, `test/account-data.test.js`, `test/security-idor.test.js`. Every slice: the new file
++alone while iterating, then `npm test` and `npm run test:pg` (PGlite and real PostgreSQL must both apply the migration
++and agree; `test/agent-schema.test.js` adds the SQLite form to that agreement).
++
++## 8. Slices — each one PR
++
++1. **Docs and proposals.** `docs/developer-projects.md` and the `policy.js:8` comment: `partner` is in the v0.83.0 enum.
++ `docs/capabilities-proposal/network.confirmation.manage.json` (first-party, `implementedBy` the four
++ `/internal/confirmations` routes). `docs/contracts-proposal/`: `identity.service-token-claims@1` gains `actor_type
++ agent`, `sub agent:agt_…`, `cap_confirm`, `act`, and requires `project_id`, `env`, `on_behalf_of` for agents; a
++ `network.confirmation.changed@1` payload. No migration. Checks: `npm test` (docs-only, sanity).
++2. **Agents.** `migrations/0010_agents.sql`, `server/developer/agents.js` (store + `ensureSchema` no-op), routes in
++ `server/developer/routes.js`, cascades in `store.js` (`revokeAppTx`, `archiveProject`, `removeMember`) and
++ `account-data.js` (`erase`, `networkPart`), `AGENT_HOST_SERVICES` in `.env.example` and the config table of
++ `docs/developer-projects.md`, `test/fixtures/sqlite/0010_agents.sql`, `test/agents.test.js`,
++ `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
++3. **Delegated grants and modes.** `migrations/0011_agent_grants.sql`, grant routes, the ceiling (reusing
++ `tokens.effectiveGrants` and `principals.grantsFor`), cascades in `setAllowance`, `decideGrant`/grant revoke and
++ `grants-admin.js` revoke + `expireDue`, `test/fixtures/sqlite/0011_agent_grants.sql`, `test/agent-grants.test.js`,
++ cases added to `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
++4. **Confirmations, owner side.** `migrations/0012_confirmations.sql`, `server/developer/confirmations.js` (store,
++ lifecycle, expiry sweep started next to `grants-admin` `start`), `/api/v1/confirmations` mounted in `server/index.js`,
++ rule routes, the store's `consume` with the step-4 re-checks, the confirmation cancel step added to every cascade of
++ slices 2–3 (pause, revoke, grant revoke and expiry, ceiling cascades, member removal, erase, app revoke, archive),
++ `test/fixtures/sqlite/0012_confirmations.sql`, `test/confirmations.test.js` creating and consuming rows through the
++ store functions (the internal routes wait for slice 7), cases added to `test/agent-schema.test.js`. Checks: `npm test`,
++ `npm run test:pg`.
++5. **Budgets.** `migrations/0013_agent_budgets.sql`, budget routes, `budget.beyond_quota`,
++ `test/fixtures/sqlite/0013_agent_budgets.sql`, `test/agent-budgets.test.js` (without the internal read), cases added to
++ `test/agent-schema.test.js`. Checks: `npm test`, `npm run test:pg`.
++6. **Internal agent read.** `GET /internal/agents/:agent` under `network.project.read` (already in the catalog), in
++ `server/developer/agents.js`, wired in `server/index.js`; tests added to `test/agent-budgets.test.js`. Checks:
++ `npm test`, `npm run test:pg`.
++7. **Internal confirmations** — *blocked on Contracts publishing `network.confirmation.manage` and the pin bump.*
++ `package.json` pin, the four `/internal/confirmations` routes, `DEFAULT_GRANTS` for the services that ask first (none
++ until a receiver ships), internal cases in `test/confirmations.test.js`. Checks: `npm test`, `npm run test:pg`,
++ `node scripts/contracts-drift.js`.
++8. **Agent tokens** — *blocked on Contracts publishing the agent claims (gap 1).* The `agent` branch in
++ `server/developer/tokens.js` and `principals.issueToken`, `cap`/`cap_confirm` split, `test/agent-tokens.test.js`.
++ Network's own guard keeps refusing agent tokens on its routes until a route needs them. Checks: `npm test`,
++ `npm run test:pg`.
++9. **Decision events and the owner notification** — *blocked on the `network.confirmation.changed@1` payload and an
++ openvibe-shared notification type.* Audit rows gain `event`; `notification-service.js` notifies the owner on create
++ (payload rules of `docs/notification-digest.md`: never the summary). Checks: `npm test`, `npm run test:pg`.
++
++Slices 2–6 ship now, in this order (each adds the next migration number); 7–9 wait only on releases outside Network and are flagged by `scripts/contracts-drift.js`.
++
++## 9. Unresolved
++
++- **Apps acting `on_behalf_of` a person** (authorization-code tokens) are not agents, and `capability@1` asks confirmation
++ of agents. Whether a third-party app's sensitive actions should also be confirmed is a separate decision (the consent
++ screen gap in `docs/developer-projects.md`).
++- **Per-agent usage display.** Owners cannot see an agent's `used` until `common.usage-recorded@1` gains an agent
++ dimension; whether an agent id may appear in a rollup is a privacy decision for Contracts.
++- **Money budgets across services** belong to Billing (T5); Network records nothing for them yet.
++- **The approval inbox UI** (Network pages or Codes/Actor) is not designed here.