# Agent Delegation Design

This document defines Airlock's user-to-agent delegation model. The shipped API
reference remains `docs/airlock_api_v1.md` and the installed
`airlock.user.documentation(...)` output; use this document for design rationale,
security rules, and implementation intent.

## Summary

Agent delegation lets a human or business user explicitly authorize an agent
account to perform limited Airlock actions on that user's behalf.

Delegation is an API authority model, not an interactive Streamlit work mode.
The Streamlit UI may help users create, view, and revoke delegation grants, but
delegated execution belongs in stored procedure calls made by bots or agent
tools. Human Streamlit work remains direct-mode: the user chooses their own
Airlock role and acts as themselves.

Delegation is not impersonation. Airlock must keep both identities visible:

- `actor_user`: the Snowflake user or service account that called the procedure.
- `principal_user`: the user on whose behalf the action was performed.
- `delegation_id`: the explicit active grant used for the action.

Good audit sentence:

```text
Deb submitted joe_timesheet_2026_05_17.csv on behalf of Joe.
```

Bad audit sentence:

```text
Joe submitted joe_timesheet_2026_05_17.csv.
```

The bad sentence hides the actor and turns delegation into impersonation.

## Product Defaults

The conservative default is:

- Delegation is disabled unless a spec explicitly enables it.
- Delegation starts with submit-style actions, not governance actions.
- Actor and principal are both evaluated by the PDP.
- Delegated files use the principal user's path scope when the spec has
  per-user isolated directories.
- The actor must be visible in `created_by`, `uploaded_by`, events, and procedure
  result context.
- The principal must be visible in delegated result context and events.
- Ambiguous delegation grants fail instead of guessing.

## Delegable Action Set

Supported delegated actions:

| Action | Include first? | Reason |
| --- | --- | --- |
| `validate_data` | Yes | Read/validation planning, no mutation. |
| `load_data` | Yes | Primary use case: agent submits a user's file. |
| `add_attachment` | Yes | Needed for reimbursements/timesheets with evidence. |
| `edit_file_workflow` | Explicit only | Workflow movement is available only when the spec policy and grant both allow it for the current step. |
| `replace_attachment` | Explicit only | Permanent attachment replacement is available only when the spec policy and grant both allow it. |
| `delete_files` | No | Destructive and not needed for first submission workflows. |
| `delete_attachment` | No | Destructive and not needed for first submission workflows. |
| workflow approval/rejection | No | Governance decision, not a simple delegated submission. |
| `admin.*` procedures | Never | Admin APIs are control-plane authority and are not delegable. |
| spec/admin operations | No | Too broad for user-to-agent delegation. |

The recommended reimbursement pattern is to let an agent prepare the business
payload and evidence, then get it into the reviewer-facing workflow state through
normal Airlock policy. There are two supported shapes:

- Configure `Submitted` as the spec's initial workflow step and use `Draft` only
  for reviewer pushback. Then `load_data` can create the reviewer-facing item
  without granting workflow movement to the agent.
- Configure `Draft` as the initial step. Then a higher-level "submit" tool should
  model submission as two audited actions: `load_data` writes the file, then
  `edit_file_workflow` advances it if the spec policy and grant both allow
  workflow movement at the current step.

A tool may present either shape as "submit reimbursement", but it must not
invent a hidden target-state argument or bypass the same PDP and expectation
checks that a direct workflow transition would run.

If workflow movement is not delegated and the spec's initial step is `Draft`, the
agent can still load the file and attachments into Draft. The principal or
another authorized reviewer must then move it forward.

Delegation action names are `user.*` procedure actions only. Admin procedures
may create, list, or revoke delegation records as control-plane operations, but
no `airlock.admin.*` procedure can be executed through `on_behalf_of_user`.

If a delegate also has direct Airlock roles, direct role authority remains
separate from delegation. A workflow transition that succeeds without
`on_behalf_of_user` is a direct user action, not delegated work. Agents should
use `on_behalf_of_user` for delegated file validation/load/attachment/workflow
calls and report direct workflow actions separately.

## Spec Configuration

Delegation belongs in spec access/workflow policy, not column validation.

Delegation is always user-level. A delegate may also hold Airlock roles for
their own work, but delegated calls must not borrow or merge those roles. At
runtime Airlock re-checks the principal user's current access to the spec; if
the principal loses access, the delegation stops authorizing work.

Spec config shape:

```json
{
  "delegation_policy": {
    "enabled": false,
    "allowed_actions": ["validate_data", "load_data", "add_attachment"],
    "principal_scope": "assigned_users",
    "workflow_step_actions": [
      {
        "step_name": "Draft",
        "step_order": 1,
        "allowed_actions": ["validate_data", "load_data", "add_attachment"]
      },
      {
        "step_name": "Submitted",
        "step_order": 2,
        "allowed_actions": ["validate_data", "load_data", "add_attachment"]
      }
    ],
    "requires_user_approval": true,
    "max_duration_days": 365
  }
}
```

Field meanings:

- `enabled`: explicit opt-in for the spec. Default is `false`.
- `allowed_actions`: delegable Airlock actions for this spec.
  `load_data` must be paired with `validate_data`, because every load validates
  the payload before writing the file manifest.
- `principal_scope`: which users may be principals. Supported value:
  `assigned_users`.
- `workflow_step_actions`: optional per-step narrowing of delegable actions. If
  present, global `allowed_actions` is only the outer allowlist; the step must
  also allow the requested action. Each row must include `step_order`; `step_name`
  is recommended for readability but is not the runtime key.
- `requires_user_approval`: whether a user must personally approve the
  delegation, as opposed to admin-created delegation only.
- `max_duration_days`: upper bound on delegation validity.

Use this for submit-style workflows: a spec admin can allow an agent to prepare
files and attachments while the file is in `Draft` or `Submitted`. If files
should arrive in reviewer hands immediately, prefer making `Submitted` the
spec's configured initial step and keeping `Draft` for reviewer pushback. If the
spec instead starts in `Draft` and the agent should be able to submit it, include
`edit_file_workflow` in both the global `allowed_actions` and the `Draft`
`workflow_step_actions` row so the agent can advance the just-loaded file. If the
spec should allow the agent to repair evidence after reviewer pushback, also
allow the relevant attachment actions in the `Submitted` and/or `Draft` rows.

`admin.validate_spec` validates this policy before save/create. `allowed_actions`
and per-step `allowed_actions` must be arrays of known user procedure actions;
admin procedure names are rejected; `load_data` must include `validate_data`;
per-step actions may only narrow the global allowlist; `workflow_step_actions`
must match configured workflow steps by `step_order`.

## Delegation Record

Candidate table:

```sql
core.agent_delegations (
  pk VARCHAR PRIMARY KEY,
  hk BINARY(32) NOT NULL,
  created_at TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP(),
  created_by VARCHAR NOT NULL,
  modified_at TIMESTAMP_LTZ,
  modified_by VARCHAR,
  principal_user VARCHAR NOT NULL,
  principal_user_norm VARCHAR NOT NULL,
  actor_user VARCHAR NOT NULL,
  actor_user_norm VARCHAR NOT NULL,
  spec_name VARCHAR NOT NULL,
  allowed_actions VARIANT NOT NULL,
  path_scope VARCHAR,
  effective_from TIMESTAMP_LTZ,
  effective_to TIMESTAMP_LTZ,
  revoked_at TIMESTAMP_LTZ,
  revoked_by VARCHAR,
  revoke_reason VARCHAR,
  comment VARCHAR,
  is_locked BOOLEAN DEFAULT FALSE
)
```

Normalize user names for lookup, but keep original display values for audit.
Older installs may still have `actor_airlock_role`; it is legacy metadata only
and must not participate in runtime authorization.

Indexes:

- `actor_user_norm`
- `principal_user_norm`
- `spec_name`
- active-window fields if Snowflake supports the desired hybrid-table index
  shape

## Procedure Contract

### Delegation Management

Admin/spec-admin procedures:

```sql
CALL airlock.admin.create_delegation(delegation_descriptor, validate_only);
CALL airlock.admin.list_delegations(spec_name, principal_user, actor_user, include_revoked);
CALL airlock.admin.revoke_delegation(delegation_id);
```

User self-service procedures:

```sql
CALL airlock.user.create_delegation(delegation_descriptor, validate_only);
CALL airlock.user.list_my_delegations();
```

`user.create_delegation` is principal-only: the caller is always
`CURRENT_USER()`. If `principal_user` is supplied, it must match
`CURRENT_USER()`. This lets asmith grant her agent access for specs she can
write to, while preventing a delegate from creating a second grant on behalf of
asmith.

Streamlit does not use `CURRENT_USER()` for delegation creation. The UI calls
the private `streamlit_internal.create_delegation` /
`streamlit_internal.revoke_delegation` procedures with the authenticated
`st.user` viewer and selected Airlock role. In that UI boundary, `app_admin`
can manage all delegations; non-admin viewers can only create or revoke
delegations where they are the principal.

The first user-facing list surface is intentionally one procedure:

```sql
CALL airlock.user.list_my_delegations('received'); -- grants where CURRENT_USER is the agent/actor
CALL airlock.user.list_my_delegations('granted');  -- grants where CURRENT_USER is the principal
CALL airlock.user.list_my_delegations('both');     -- default overview
```

`received` is the agent-oriented lens. It returns `DELEGATION_ID`,
`PRINCIPAL_USER`, `ALLOWED_ACTIONS`, and a structured `ACTION_CONTEXT` object so
an agent can call delegated procedures with `on_behalf_of_user` without querying
broad admin-only metadata. `delegation_id` is an advanced disambiguator only
when more than one active grant matches.

Airlock SQL APIs are stored procedures. Agents should use `CALL
airlock.user...`; `SELECT * FROM TABLE(...)` is not the procedure call form.
Use named arguments once optional parameters appear, and omit optional
arguments you are not using instead of passing placeholder `NULL` values or
typed casts.

### Delegated User Actions

Delegated action procedures use two optional trailing parameters:

```text
on_behalf_of_user VARCHAR DEFAULT NULL,
delegation_id VARCHAR DEFAULT NULL
```

Example:

```sql
CALL airlock.user.validate_data(
  spec_name => 'timesheets',
  file_content => :csv,
  on_behalf_of_user => 'joe'
);

CALL airlock.user.load_data(
  spec_name => 'timesheets',
  file_content => :csv,
  filename => 'joe_timesheet_2026_05_17',
  on_behalf_of_user => 'joe'
);

CALL airlock.user.add_attachment(
  spec_name => 'timesheets',
  file_path => 'joe',
  file_filename => 'joe_timesheet_2026_05_17',
  attachment_content_base64 => :receipt_base64,
  attachment_filename => 'receipt.png',
  on_behalf_of_user => 'joe'
);

CALL airlock.user.replace_attachment(
  spec_name => 'timesheets',
  file_path => 'joe',
  file_filename => 'joe_timesheet_2026_05_17',
  attachment_id => '...',
  attachment_content_base64 => :receipt_base64,
  attachment_filename => 'corrected_receipt.png',
  on_behalf_of_user => 'joe'
);

CALL airlock.user.edit_file_workflow(
  spec_name => 'timesheets',
  path => 'joe',
  filename => 'joe_timesheet_2026_05_17',
  action => 'advance',
  comment => 'Submitted by agent',
  on_behalf_of_user => 'joe'
);
```

For inline CSV, omit `path`; `path` is only for staged file paths. For delegated
calls, Airlock resolves the principal user's folder/lens before checking the
delegation. `add_attachment` is for follow-up evidence on an existing file; pass
the file path and filename returned by the delegated load, and keep passing
`on_behalf_of_user`. If a follow-up mutation omits `on_behalf_of_user`, Airlock
evaluates it as a direct actor call, which may correctly fail against the
actor's own isolated directory. When `load_data` includes
`attachment_content_base64`, that call already registers the first attachment;
use a distinct `attachment_tag` for any extra follow-up evidence, or skip the
follow-up attachment. `replace_attachment` is permanent and requires an explicit
`replace_attachment` action in both the spec policy and the active grant.
`edit_file_workflow` also requires direct workflow/PDP permission for the
principal and an explicit `edit_file_workflow` grant at the file's current
workflow step. Only pass `path_scope` when deliberately targeting a non-default
shared/public scope.

### Delegated Submit and Reviewer Pushback

For human-facing workflows, prefer this mental model:

1. The human principal grants an agent scoped delegation for a spec.
2. The agent validates and loads only the business payload required by the spec.
3. The file reaches the reviewer-facing state either because `Submitted` is the
   spec's configured initial step, or because the agent is explicitly allowed to
   call `edit_file_workflow` after loading a Draft.
4. The reviewer works the Submitted item. If something is wrong, the reviewer
   returns it to `Draft` with a workflow comment.
5. The principal sees the returned Draft item in My Work, reads the reviewer
   comment, fixes or re-sends the content, and resubmits before the expectation
   due date.

This keeps source data clean. A reimbursement payload should not contain
`approval_status`, `workflow_step`, reviewer comments, or resubmission metadata;
Airlock carries those as workflow, event, attachment, and expectation state.

If an MCP server or agent skill exposes a convenience action such as
`submit_reimbursement_on_behalf_of`, implement it as `describe_spec` +
`validate_data` + `load_data` + optional `edit_file_workflow`. The tool should
report the returned `PATH`, `FILENAME`, final workflow state, and any expectation
or delegation denial code. It should not invent a hidden target-state argument
unless the installed Airlock API documents one.
`load_data` can also return expectation findings: `EXPECTATION_BLOCKED` means a
strict expectation prevented the load; `EXPECTATION_WARNING` means the load
succeeded but an operational expectation still needs attention.

`delegation_id` can be optional only when exactly one active grant matches the
actor, principal, spec, path lens, and action. If more than one active grant
matches, return `AMBIGUOUS_DELEGATION`; then the caller may retry with
`delegation_id`.

If a delegation has `path_scope`, runtime authorization treats it as a hard
scope limit. The requested staged path or inline `path_scope` must resolve to
the same value; otherwise the grant does not match.

Delegation denials are table-shaped whenever the delegated user procedure has a
fixed return schema. `validate_data` and `load_data` return `STATUS = 'error'`
with one `ISSUES` entry containing the stable delegation code. Attachment
procedures return `STATUS = 'error'` with `CODE` set to the delegation code.
Workflow movement returns `STATUS = 'error'` with the code inside
`VALIDATION.issues`. Agents should branch on those codes, not parse prose.

## PDP Contract

The PDP should evaluate delegated calls in this order:

1. Normalize `actor_user` from `CURRENT_USER()`.
2. Normalize `principal_user` from `on_behalf_of_user`.
3. If no `on_behalf_of_user`, use existing non-delegated behavior.
4. Load the spec and verify `delegation_policy.enabled`.
5. Verify the requested action is allowed by spec policy.
6. Verify actor's Airlock role is allowed by spec policy.
7. Resolve an active delegation record.
8. Evaluate principal access to the spec/path/action.
9. Evaluate actor delegate access to the spec/path/action.
10. Apply normal workflow, attachment, reference, and validation checks.

PDP response should include:

```json
{
  "delegation": {
    "delegated": true,
    "delegation_id": "D123",
    "actor_user": "DEB_AGENT",
    "principal_user": "JOE",
    "action": "load_data"
  }
}
```

## Path Scope Rule

When a spec uses isolated per-user paths, delegated uploads should default to the
principal user's path key, not the actor's path key.

Example:

```text
Deb submits for Joe
Spec has isolated user directories
Airlock writes under Joe's path scope
Audit says actor=Deb, principal=Joe
```

This preserves the business meaning of "Joe's timesheet" while still showing who
actually submitted it.

## Event and Manifest Contract

For first implementation:

- Keep `created_by`, `uploaded_by`, and procedure `username` values as actor.
- Add explicit delegation context to event payloads/results.
- Consider adding `principal_user` / `on_behalf_of_user` columns only when UI,
  audit queries, or external consumers need direct filtering.

Do not silently store principal in existing actor columns. That would make
delegation indistinguishable from impersonation.

## Licensing Contract

For the first implementation:

- The actor user must satisfy the same named-license requirement as any other
  caller of `airlock.user.*`.
- Delegated actions must not automatically claim or bill a seat for the principal
  user.
- The principal user must still be a valid Airlock identity for the spec/path
  policy being evaluated.

This avoids surprise billing when one approved agent submits for many users. If a
future pricing model charges delegated principals separately, that must be an
explicit product and Marketplace billing change, not a side effect of delegation.

## Result Contract

Every delegated mutation should return the normal Airlock result plus:

```json
{
  "DELEGATED": true,
  "ACTOR_USER": "DEB_AGENT",
  "PRINCIPAL_USER": "JOE",
  "DELEGATION_ID": "D123"
}
```

Denials should use stable codes:

- `DELEGATION_DISABLED`
- `DELEGATION_ACTION_NOT_ALLOWED`
- `DELEGATION_NOT_FOUND`
- `DELEGATION_EXPIRED`
- `DELEGATION_REVOKED`
- `AMBIGUOUS_DELEGATION`
- `DELEGATION_PRINCIPAL_ACCESS_DENIED`
- `INVALID_DELEGATION_POLICY`

## UI Contract

Streamlit should not provide an "acting as" work mode for delegated execution.
Its job is enablement and tracking:

- create a grant for my agent
- list active and inactive grants involving me
- revoke grants I issued
- show delegate-only users that they have no direct Airlock role without treating
  that as broken

Agent/procedure output should say:

```text
Submitting as Deb for Joe
```

Avoid:

```text
Logged in as Joe
```

Spec admin should configure delegation near access/workflow controls. User
settings should list active delegations both directions:

- agents who can act for me
- users I can act for

## MCP and Agent Skill Contract

MCP tools should expose `on_behalf_of_user` on tools that support delegation.
`delegation_id`, `path_scope`, and role/path lenses are advanced overrides, not
the default call shape. Tool descriptions must say the call remains audited as
the actor acting for the principal.

Agent skills should instruct agents:

- never log in as the principal
- pass `on_behalf_of_user` for delegated work
- keep passing `on_behalf_of_user` on every follow-up delegated mutation; omitting
  it changes the call back to direct actor mode
- use `list_my_work_items` only for direct-role workflow work; delegated workflow
  work should be discovered from `list_my_delegations` and the delegated
  procedure results
- treat workflow transitions without `on_behalf_of_user` as direct-role actions
- report delegated results as "Submitted as Deb for Joe"
- preserve delegation denial codes

## Testing Contract

Required tests before shipping:

- non-delegated calls still behave exactly as before
- delegation disabled by default
- spec delegation disabled denies delegated call
- missing/expired/revoked delegation denies call
- principal access denied denies call
- valid delegation writes to principal path scope for isolated user specs
- result and event include actor, principal, and delegation id
- ambiguous active delegation fails without `delegation_id`

## Implementation Rule

No delegated action should run unless Airlock can answer:

```text
Who acted?
For whom?
Under which delegation?
For which spec, path, and action?
Was that grant valid at execution time?
```
