# SOP Virtual Level — a model of delegated permissions

**Status:** Principle 1 complete. Principle 2 — the tool, permission and data axes (PostgreSQL RLS / subject view delegation) — fully implemented.
**Date:** 2026-09-18 · **Branch:** `master`

Virtual Level is GoClaw's model of delegated permissions for SOP runs: a request at user level never reaches admin or system permissions, and within the user level it does only what a SOP defines.

---

## 1. Principle

> **A user level never reaches admin or system permissions, under any circumstances.
> Within the user level it does only what a SOP defines.**

These are two separate statements, and both must hold:

1. **No escalation** — a request that arrives through app_api has no path to the admin/system tier.
2. **Bounded by the SOP** — what that user can do beyond a plain user is defined by name in a SOP, and nothing else.

Most systems do the first and forget the second: they hold the user at a low level, and then, when work cannot get done, add a "temporarily make them admin" escape hatch. That escape hatch undoes the first principle exactly. Virtual Level is a proposal to make that escape hatch **not exist**.

---

## 2. Stage 1 — implemented: the escalation path is cut

### The vulnerability found

The app_api channel's `X-User-Role` header (or the JWT `role` claim) went straight into `RuntimeRequestMeta.GoClawRole` — that is, into **GoClaw's permission tier**. There was no check and no allowlist.

The consequence was confirmed by a test:

| `X-User-Role` | `isPrivilegedActor` | `isHumanAdmin` |
|---|---|---|
| `user` | false | false |
| `admin` | **true** | **true** |
| `system` | **true** | **true** |

`isHumanAdmin` is the highest tier — it guards changing identities, reading secrets and the like. In other words, the holder of an app_api key, or a backend that passed on a user-supplied role without checking it, could become a **full admin**.

A second, longer-lived hole: `AutoProvision(...)` **stored its fourth argument as the `GoClawRole` in the pairing store**. The pairing store is shared across channels. So a single `X-User-Role: admin` request could create a **permanent** admin user that another channel would read back.

### The fix

**Not** a blocklist. Upper/lower case, spaces, a Cyrillic `а` that looks like a Latin `a` — there is always a way around a blocklist. Instead, **the data flow was cut**:

```go
// internal/channel/appapi/auth.go
const GoClawPrivilegeTier = "user"
```

`identity.Role` stays the **business role** (`engineer`, `manager`) — used for agent routing and SOP scoping, never for permissions. `GoClawRole` is constant.

Locked down by a test ([app_api_privilege_test.go](../../internal/gateway/app_api_privilege_test.go)) with 11 spellings — `admin`, `SYSTEM`, `  admin  `, `аdmin` with a Cyrillic first letter, and so on. The test was confirmed to **fail** on the old code.

> **Operational impact:** admin actions that used to go through app_api no longer work. An admin comes in through the Web UI or a paired channel, where the role comes from the pairing store — that is, from server-side state.

---

## 3. Three axes

In the code as it was, two different concepts were squeezed into one field, which is the root of the vulnerability above. The design separates them:

| Axis | Values | Where it comes from | Grants permissions? |
|---|---|---|---|
| **Tier** | `system` / `admin` / `user` | Server side: the pairing store, or fixed for app_api | Yes |
| **Business role** | `engineer`, `accountant`, `manager` | Request headers / JWT / org directory | **No** |
| **Virtual Level** | A named, bounded grant in a SOP | The SOP definition + a scope derived from the subject | Yes, but **additive**; it never raises the tier |

The key difference: a Virtual Level is a **union**, not an escalation.

```
permissions = user tier ∪ the SOP's grant
```

never

```
permissions = admin tier   ← this case does not exist
```

---

## 4. What a Virtual Level is

A **named set of capabilities** declared on a SOP, valid only while that SOP runs and only on that subject's rows.

```yaml
# Inside a SOP definition
virtual_level:
  name: expense-report-reader
  grants:
    tools:
      - db_query
    datasets:
      - view: v_expenses
        scope: subject        # subject | team | department | tenant
    # The following are NEVER written here — the validator refuses them:
    #   writing system/bootstrap files, secrets, managing cron/SOPs,
    #   moderation, memory maintenance, agent_config
```

**How a SOP gets one.** Only by sending the whole SOP JSON, `virtual_level` included, to
`POST /api/sop` (with the gateway token). The `sop_save` tool has no `virtual_level`
parameter and rebuilds the SOP from what it is sent, so a save through it removes an existing
virtual level; the Web UI's SOP form does not carry the field either, and saving there removes
it too. `sop_rollback` restores an archived version with the virtual level it had.

### Scope levels — enforced, not declared

`scope` decides which session settings are bound:

| Scope | Settings bound |
|---|---|
| `subject` | `subject_ref` |
| `team` | `+ subject_manager` |
| `department` | `+ subject_department` |
| `tenant` | `+ subject_tenant` |

So a SOP granted only `subject` **cannot read** a view that filters by `department` — that setting is never bound, so `current_setting` fails. To see more widely you must change the virtual level; writing a different query does not help.

### Where the restriction is applied

**SQL is not parsed.** Checking statements the LLM wrote with a regex or parser is the same mistake as trusting the prompt. Instead, `SET LOCAL ROLE` — the datasource's `delegated_role` has `SELECT` only on the granted views. Write `SELECT * FROM expenses` and **Postgres itself** answers `permission denied`.

Without a `delegated_role` configured, a delegated query is refused outright — it is not run with the pool's own permissions.

**What it is NOT:**

- **Not** a temporary admin. `isPrivilegedActor(delegatedCtx)` is always `false`.
- **Not** an instruction in the prompt. The LLM is not a security boundary (OWASP LLM01) — a sentence like "show only your own rows" can be overridden by prompt injection.
- **Not** a parameter the user supplies. `scope: subject` is derived from the subject; a caller cannot pass `user_id=<someone else>` (OWASP IDOR).

---

## 5. Subject-derived scoping

A constraint from an earlier discussion that still holds:

**No attributes are stored in the cron/SOP tables.** Only a stable `subject_ref` (the employee ID). Department, manager and team are resolved **at run time** from the org system.

Why: job titles change and people leave. A copied attribute goes wrong **silently** — the SOP would keep producing last month's department report.

```mermaid
flowchart LR
    DB["<b>cron_jobs / sop_runs</b><br/>subject_ref = 'emp_10482' (only this is stored;<br/>department, manager, team are NOT stored)"] -->|"at run time"| ORG["<b>org.Resolve('emp_10482')</b><br/>→ { department, manager, team, tenant }"]
```

The org directory contract is modelled on the field names of the **SCIM enterprise user schema** (RFC 7643) — even when the backing system is an internal ERP. Why: those fields are already defined, and the translation layer stays in one place.

### Implemented (stage 3)

The `internal/org` package:

- **`Person`** — with the field names of SCIM's core + enterprise user extension
  (`department`, `division`, `manager`, `employeeNumber`, `active`). Even for an
  internal ERP, the translation happens in one place, inside the adapter.
- **`Directory`** interface — `Resolve(ctx, subjectRef) (*Person, error)`.
- **`Cached`** — a short TTL (default 60 s). It caches definite answers (the
  person was found, or is certainly absent) and **does not cache network
  errors** — those are not facts about the organisation, and caching one would
  stretch a single outage over a minute.
- **`SQLDirectory`** — the first backend. It reads the organisation's own
  employee table through a configured datasource. The operator writes the
  lookup query (every schema differs), and columns are mapped by name.

The first backend is SQL rather than MS Graph: employee data usually already
lives in the ERP database, it needs no new credentials, and it can be tested
directly. **MS Graph / Entra is not done** — it is ready to slot in behind the
same interface.

At run time `db_query` binds `goclaw.subject_department`,
`goclaw.subject_manager` and `goclaw.subject_tenant` along with the subject —
only those the directory actually returned. An unresolved attribute is **not
bound as an empty string**: the view would then return 0 rows and look
legitimate, as if "there were no expenses this month". Left unbound,
`current_setting` fails, and a view that needs the attribute fails loudly.

---

## 6. Where it is enforced

**In the data layer, not in the prompt.**

```sql
-- A parameterised view. The subject comes from a connection variable,
-- not from SQL the LLM wrote.
CREATE VIEW v_expenses AS
  SELECT * FROM expenses
  WHERE employee_id = current_setting('goclaw.subject_ref');
```

The LLM calls `db_query`, but the view decides what comes back. Even if the LLM is talked into writing `WHERE 1=1`, the view's filter still applies. On Postgres, Row-Level Security gives a stronger guarantee.

This is the most important point: **the LLM must not be the safeguard.**

### The binding mechanism — implemented

`db_query` binds the subject during delegated work. Four things are required:

1. **Inside a transaction.** Verified on Postgres 17: without a transaction the setting stays on the pooled connection, and the next query to take that connection — quite possibly someone else's — **reads the previous person's rows**. A rollback clears the setting.
2. **Parameterised** — `set_config($1, $2, true)`; the subject goes in as a value and is never spliced into the statement.
3. **Refused when not Postgres.** ClickHouse/MySQL/Mongo have no mechanism to bind a subject, so the query is refused outright — by `db_query` and by `mongo_query`. Refusing is better than silently returning every row. `db_analyze` is refused in a delegated run on every driver: it reads the whole schema, and on Postgres sample values from `pg_stats`, as the datasource's own user.
4. **No SQL from text.** A custom setting such as `goclaw.subject_ref` can be set by any role, so `SET LOCAL ROLE` does not stop a query from changing it; the checks before the query do. `set_config` is refused by name, and so are the functions that run SQL given as a string — `query_to_xml` and its variants, `ts_stat`, `ts_rewrite` — because a `set_config` assembled from pieces inside that string is invisible to any check on the query text. Before they were refused, a delegated run for one employee read another's rows this way on a live Postgres (`TestQueryScope_LiveDynamicSQLCannotRebindSubject`). The list is a denylist (`sqlForbiddenCalls` in [tool_db.go](../../internal/agent/tool_db.go)), so it covers the built-in functions only: a function of your own that runs SQL text (PL/pgSQL `EXECUTE`) and is granted to the `delegated_role` reopens the hole. Do not grant the `delegated_role` such functions, and where nothing else on the database uses them, revoke the built-in ones from `PUBLIC`:

   ```sql
   REVOKE EXECUTE ON FUNCTION
     query_to_xml(text, boolean, boolean, text),
     query_to_xmlschema(text, boolean, boolean, text),
     query_to_xml_and_xmlschema(text, boolean, boolean, text),
     ts_stat(text), ts_stat(text, text),
     ts_rewrite(tsquery, text)
   FROM PUBLIC;
   ```

   Row-Level Security policies that read `current_setting('goclaw.subject_ref')` trust the same setting and have the same limit.

When nothing is bound, `current_setting('goclaw.subject_ref')` fails — fail closed.

---

## 7. Prerequisite — the cron `system` split

In the code as it was ([cron_scheduler.go](../../internal/agent/cron_scheduler.go), `runCronJob`; today `cronRequestMeta`):

```go
scopedCtx = WithRuntimeRequestMeta(scopedCtx, RuntimeRequestMeta{
    UnifiedUserID: "cron-system",
    GoClawRole:    "system",     // ← isPrivilegedActor = true
    Channel:       "cron",
})
```

Every cron job ran with `system` permissions. A delegated SOP **must not inherit** that — otherwise a SOP acting on an employee's behalf would have full permissions.

So cron is split into two kinds:

| Kind | Who wrote it | Runs as |
|---|---|---|
| **system cron** | Admin (maintenance, indexing, cleanup) | `cron-system` / `system` — as before |
| **delegated cron** | A SOP on an employee's behalf | `subject_ref` / **`user`** + the virtual grant |

This split is the **prerequisite** for everything else. Without it, there is no point building Virtual Level.

> **Implemented (stage 2).** `cronRequestMeta()` branches on whether `CronJob.SubjectRef`
> is empty. Empty: the previous `cron-system` / `system`. Set: it runs as the
> subject, at the `user` level, with a `Delegation` attached. The trace's owner
> is the subject too — otherwise the audit record would look as if the system
> itself had pulled the employee's data. An `agent …` command job with a
> subject sends it to `/api/webhook` as `subject_ref`, and the gateway builds
> the turn's identity through `DelegatedCronMeta`, which calls
> `cronRequestMeta` with no SOP, in a session of the subject's own. It used to
> run as its creator, or as the system on the web channel. With no SOP there
> is no virtual level: the turn is granted no view, and its `db_query` is
> always refused. Like a delegated SOP run, it works on org memory only (no
> personal memory, no global or `agent_config` writes).

### The structure

```go
// RuntimeRequestMeta.Delegation (internal/agent/scope.go)
type Delegation struct {
    SubjectRef string         // on whose behalf the run acts
    SOPID      string         // the SOP whose definition bounds the run
    Scope      string         // subject | team | department | tenant; empty = subject
    Views      []string       // the relations the SOP grants, lowercased
    Datasets   []DatasetGrant // the dataset grants, with their datasource constraints
}
```

`cronRequestMeta` fills `Scope`, `Views` and `Datasets` from the SOP's virtual level. A SOP
with none still runs delegated, with the narrowest scope and no granted views, so a scoped
query has nothing it may read.

The invariant — **implemented** as the first check of each of the three
predicates in `authz.go`:

```go
if meta.Delegation != nil {
    return false        // no later branch can reverse this
}
```

This holds even when `GoClawRole` says "system", because meta is **copied**
between contexts — `RunSOPV2` copies its parent's meta, and so do subagents. A
check that looked only at the role field would let one privileged ancestor
escalate everything below it.

Test coverage ([delegation_authz_test.go](../../internal/agent/delegation_authz_test.go)):
it fails when the ceiling is removed; the ceiling survives a copied meta;
non-delegated work keeps its permissions; a real tool (`cron create`) refuses;
`SubjectRef` survives a DB round trip.

---

## 8. What already exists

This does not start from zero. The code already has:

- **`SOP.Tools`** — SOP-level tool scoping, merged with the step's. Virtual Level's `grants.tools` builds on it.
- **`SOP.Prerequisites`** — the artifacts a SOP needs (tool/view/table/permission), checked on save and before a run.
- **`CapabilityRequest`** ([capability_requests.go](../../internal/agent/capability_requests.go)) — a record of requests such as "this SOP needs this capability, please approve, admin". Its `Kind` is `tool | view | table | permission` — **exactly the vocabulary Virtual Level needs**.

In other words, the approval workflow already exists. What is missing: an approved capability does not become a **run-time grant**.

---

## 9. Progress — verified against the code

Established by reading the code on 2026-08-29. The actual state, not a design statement.

### Principle 1 — no escalation: **complete**

| Component | State | Evidence |
|---|---|---|
| The app_api escalation path | ✅ cut | `GoClawPrivilegeTier` is constant; [app_api_privilege_test.go](../../internal/gateway/app_api_privilege_test.go) locks it down with 11 spellings and was confirmed to fail on the old code |
| Pairing store contamination | ✅ stopped | `AutoProvision` no longer stores the caller's role |
| Other channels | ✅ already correct | Audited — below |

The audit of the other channels (checked afresh during this work):

- `handler_api.go`, `handler_websocket.go` — the role comes from the **pairing store** (server-side state). The `channel=web` → `system` path is protected by the dashboard token (`/gateway` and `/api/` are behind `AuthMiddleware`).
- `openai_compat.go` — never sets `GoClawRole` → empty → no privileges. The correct fail-closed behaviour.
- `webhook_router.go` — `webhook-system`, deliberately excluded from `isPrivilegedActor`.

**app_api was the only gap.** It is now closed.

### Principle 2 — bounded by the SOP: **complete for delegated runs**

This principle has two axes. The tool axis was ready; the data axis was at zero when this
work started and is now in place for delegated runs.

| Axis | State | Details |
|---|---|---|
| **Tool axis** | ✅ working | `SOP.Tools` + `step.Tools` → `SkillsAllow`. Not merely what the LLM is offered: dispatch (`Execute` in [registry.go](../../internal/agent/registry.go)) refuses a tool missing from the list. In `SOPStepMode` only internal, side-effect-free tools pass automatically — even `message` must be declared. An empty step falls back to the SOP-level tools; when both are empty it runs text-only; `"*"` = every tool; Virtual Level always narrows and never widens. RAG tool selection never runs in a SOP. |
| **Data axis** | ✅ confined (delegated runs) | A delegated `db_query` ([db_scope.go](../../internal/agent/db_scope.go)) runs only on Postgres, in a read-only transaction, under the datasource's `delegated_role` (`SET LOCAL ROLE`), with `search_path` pinned to `public, pg_catalog, pg_temp`, and with the subject and its resolved attributes bound by `set_config(..., true)`. The relations the query reads are checked against the granted views first. Without a `delegated_role`, or on another driver, the query is refused. A run that is not delegated (no `subject_ref`: a system cron job, a manual run) is not confined this way and reads what the datasource's own user can. |

A related safeguard that holds: `db_query` is restricted to SELECT/SHOW/DESCRIBE/EXPLAIN by `isReadOnlyQuery` — enforced in code ([tool_db.go](../../internal/agent/tool_db.go)), not promised in a prompt. There is no write risk; the **read scope** was the problem.

### Stages 2–4: component by component

| Component | State | Note |
|---|---|---|
| cron `system`/`delegated` split | ✅ **done** | `CronJob.SubjectRef` → branches `cronRequestMeta()` |
| `Delegation` structure | ✅ **done** | `RuntimeRequestMeta.Delegation`: `SubjectRef`, `SOPID`, `Scope`, `Views`, `Datasets`, filled from the SOP's virtual level |
| No-escalation invariant test (delegated) | ✅ **done** | [delegation_authz_test.go](../../internal/agent/delegation_authz_test.go) — confirmed to fail when the ceiling is removed |
| Org directory adapter | ✅ **done** | `internal/org`: SCIM-shaped `Person`, TTL cache, SQL backend. MS Graph not done |
| Binding the subject on the datasource | ✅ **done** | `db_query` uses a transaction + `set_config` when delegated; refuses when not Postgres. Has a live Postgres test |
| Parameterised views / RLS | ⏳ operator's job | The mechanism is complete; the organisation defines the views/policies and the role itself |
| `virtual_level` in the SOP schema | ✅ **done** | `SOP.VirtualLevel`; the scope levels are enforced |
| Grant validator | ✅ **done** | `ValidateVirtualLevel` — refuses admin tools, on save and at run time |
| Restricting by role | ✅ **done** | `SET LOCAL ROLE` — Postgres closes the base tables. Has a live test |

### What turned out closer than expected — and what did not

`CronJob` already had `CreatedByUserID`, described as "audit logging only — execution always runs as a system (cron) identity". From that, the conclusion had been: **"stage 2 is not adding a new field but making an existing one effective."**

**That conclusion was wrong.** As found during implementation: `CreatedByUserID` and the subject answer different questions.

| Field | Question |
|---|---|
| `CreatedByUserID` | Who set up this schedule |
| `SubjectRef` | Whose permissions it runs with |

When an admin schedules a report for an employee, these **diverge**: the creator is the admin, the subject is the employee. Had `CreatedByUserID` become the run-time permission, every job an admin created would have run **with admin permissions** — not a fix but a new escalation path. So `SubjectRef` was added as a separate field.

The `tool | view | table | permission` vocabulary of `CapabilityRequest` and the checks of `SOP.Prerequisites` are also ready (section 8).

### Summary

```
Principle 1 (no escalation)      ████████████████████  complete
Principle 2 — tool axis          ████████████████████  complete (already existed)
Principle 2 — permission axis    ████████████████████  complete (stage 2)
Principle 2 — data axis          ████████████████████  complete (PostgreSQL RLS / view scoping)
```

The riskiest combined gap used to be: **a SOP run by cron has `system` permissions AND reads every row**.
Stage 2 made sure a job with a `subject_ref` now runs at the `user` level, with no way to escalate.
The next stage closed the **data axis** completely, restricting each request to the employee's permitted rows through `DatasetGrant` and `queryScope` (`goclaw.subject_ref` parameterised views, lexically scoped CTE validation, transaction search_path isolation).

---

## 10. Open questions

1. **Org directory** — should the first adapter be MS Graph / Entra, or a direct connection to the internal ERP? Run-time resolution adds to every SOP run; how long should the cache last (proposal: 60 s, 0 for risky SOPs)?

2. **Database** — do the views to scope already exist, or do they need defining? Is it Postgres (RLS available), or another source?

3. **Approval flow** — should a Virtual Level grant **require** a `CapabilityRequest` approval? (Proposal: yes — a SOP author must not be able to write grants at will.)

4. **`scope` levels** — are `subject | team | department | tenant` enough? A manager's direct reports ("my team") is a separate shape that comes from the org directory.
