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
33 changes: 33 additions & 0 deletions .changeset/execution-principals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@proofoftech/breakwater': minor
'@proofoftech/flowsafe': minor
'anchorage-agent-starter': patch
---

Add first-class execution principals so automated work stops impersonating people.

Every automated path previously fabricated a human to satisfy the one identity the platform had: the schedule tick, cron SLA maintenance, signal-provider delivery, and the suspension-reconcile bridge all minted `role: 'operator'`. That lost provenance and gave autonomous execution an operator's authority.

Breakwater's `Actor` gains an optional `kind` (`human` | `service` | `agent` | `system`, absent meaning human), and both `RBACMiddleware` and `createGuardedAgent` gain `allowedPrincipalKinds`, defaulting to `['human']`. The gate checks kind before role and does not consult the role allowlist for a non-human kind, because an automated principal carries a role only to satisfy the required field — consulting it would either admit whatever role the host projected, or force hosts to allow that role and thereby admit real humans holding it. Both the processor gate and the direct-call gate enforce it. **An existing agent therefore denies every automated principal without a config change.**

Flowsafe adds `ExecutionPrincipal`, with `purpose` required on every automated kind, and persists it in agent-run state and approval resume targets. `AgentMeta.allowedAutomation` declares which principal kinds may enter on which entry paths; absent or empty denies all automated entry, and an optional host authorizer can only narrow it further. `ApprovalActor` is unchanged and still means an authenticated human at the HTTP boundary or a reviewer deciding an approval — a human approval never transfers the decider's authority into the resumed run.

The `@proofoftech/flowsafe/agent-host` entry point exports its automation policy types, including `AgentAutomationRule`, `AutomationCheck`, `AutomatedEntryRequest`, and `AutomatedEntryAuthorizer`, so public catalog and host signatures never require deep imports.

`ApprovalService` gains `createAsPrincipal` and `supersedeStaleAsPrincipal` for trusted platform bridges. They replace the human role gate with a kind-and-tenant check rather than widening it. There is deliberately no principal-taking `decide`, `claim`, or `delegate`.

`trustAutomationPrincipal()` returns a branded, frozen canonical clone rather than the caller's own object. Validating a principal and handing the same reference back left the vouch time-of-check/time-of-use: the caller kept a mutable alias and could rewrite a vouched `system` principal into `{kind:'human', role:'admin'}` before the service read `kind`. The trusted entries now recheck the own brand, the automated shape, the kind, and that every field is a plain data property — an accessor survives `Object.freeze` and would reopen the same hole — instead of trusting a parameter type that does not exist at runtime. `ExecutionPrincipal` fields are `readonly`.

`AutomatedExecutionPrincipal` is added for duties that want provenance but derive no authority from the principal, so the trust brand is demanded only where it is read. `sweepSLA` and `SlaSweepMaintenanceOptions` take it, and `sweepSLA` refuses a human or malformed principal outright: it writes across every tenant, and a human there would stamp `principalKind: 'human'` onto cron escalations. `TRUSTED_AUTOMATION` is not on the package barrel — `trustAutomationPrincipal` is the sanctioned constructor.

Audit correlation now carries `principalKind`, `principalId`, `purpose`, and `delegatedBy` alongside the existing tenant, run, thread, and entry-path fields.

`x-flowsafe-actor` and `x-flowsafe-role` are retired from the wire. The principal is now the sole identity channel: a thread Durable Object projects `scope.actor` from it, so a host's separate `TenantContext.actor` can no longer disagree with what executes. Both header constants are removed from `@proofoftech/flowsafe/do-runner`; the topology strips the names on send and forward, and `createTenantResolver` still refuses them on inbound requests so a mixed-version client fails loudly.

`queueApprovalForSuspension`, `reconcileApprovalsForSummary`, and `resumeRunWithRequeue` take a `systemActorId` string instead of a principal, and mint their own bookkeeping identity against the service's tenant binding. Hosts no longer perform a trust assertion for the platform's own bookkeeping. `ApprovalService` exposes its `tenantId` for that.

The principal travels to a Durable Object in a trusted `x-flowsafe-principal` header that `createThreadTopology` stamps on every send and forward. A thread DO refuses a request that carries none rather than treating the caller as a human, and `createTenantResolver` refuses the header on inbound requests exactly as it does the tenant, actor, and role headers.

BREAKING for in-flight state, deliberately and without an upgrade path: `AgentRunRecord` is version 2 and `agent-thread` resume targets now store an `ExecutionPrincipal`. Records written by the previous release fail closed, so a suspended agent run started before this upgrade cannot resume. A version-1 record cannot be upgraded honestly — a `schedule.fire` run stored `role: 'operator'`, so reading it back as a human would launder exactly the authority this change removes. Flowsafe's breakwater peer floor moves to `>=0.7.0`. `rejectReservedAgentContext` is removed from `@proofoftech/flowsafe/agent-host`; it was exported but never called on any path, and every real caller uses `sanitizeStoredAgentContext`.

A thread Durable Object now requires the principal header on every request, so a deployment whose Worker and Durable Object resolve different `@proofoftech/flowsafe` versions returns 403 until both sides ship this release. Cloudflare's single-bundle model makes that skew unlikely, but there is no negotiation.
32 changes: 28 additions & 4 deletions docs/durable-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,34 @@ interface AgentModule {
title: string;
description: string;
allowedRoles?: readonly ApprovalRole[];
allowedAutomation?: readonly {
kind: 'service' | 'agent' | 'system';
entryPaths: readonly AgentEntryPath[];
}[];
};
agent: GuardedAgentHandle;
}
```

`allowedRoles` governs authenticated humans. `allowedAutomation` governs everything else, and **an omitted or empty list denies every automated entry.** A schedule tick, a signal-provider delivery, a notification dispatch, or a delegating agent reaches an agent only if that agent names the principal kind together with the exact entry path. Naming the path and not just the kind is what stops an agent that may fire on a schedule from also accepting webhook-delivered signals.

`approval.resume` is never declared: resuming is implied by the kind that started the run. Requiring hosts to list it would mean an automated run that suspends for approval is stranded the moment a human approves it. A kind removed from the declaration entirely can still no longer resume.

The guarded handle must agree. `createGuardedAgent({ allowedPrincipalKinds })` decides which kinds may execute at all, and catalog construction refuses a module whose declared automation kinds differ from it — so a host cannot advertise automation Breakwater will refuse, or register an automation-capable agent its catalog will never route to.

```typescript
// An agent driven by a schedule and by provider deliveries.
allowedAutomation: [
{ kind: 'system', entryPaths: ['schedule.fire', 'notification.dispatch'] },
{ kind: 'service', entryPaths: ['signal.notification'] },
],
// and on the guarded agent:
allowedPrincipalKinds: ['human', 'system', 'service'],
```

The Worker receives metadata only. The thread Durable Object constructs the complete module because its model, storage, runtime, pub/sub, connector, and database objects belong to that instance.

Catalog construction rejects path-unsafe or duplicate ids, empty descriptions, invalid role lists, metadata/handle id mismatches, and metadata roles that differ from the guarded handle. An omitted role list uses `RUN_START_ROLES`.
Catalog construction rejects path-unsafe or duplicate ids, empty descriptions, invalid role lists, metadata/handle id mismatches, metadata roles that differ from the guarded handle, and automation declarations that name a human kind, an unknown entry path, `approval.resume`, a repeated kind, or a kind set differing from the guarded handle. An omitted role list uses `RUN_START_ROLES`; an omitted automation list denies all automated entry.

Mount `createAgentRouter()` through `createFlowsafeWorker({ buildAgentRouter })`. It exposes:

Expand All @@ -64,7 +84,7 @@ The start body accepts only `{"prompt":"..."}`. The router caps the raw UTF-8 bo

Each stream line contains the next reconnect cursor and one event. Replay depends on the configured Mastra cache and is not process-restart durable. When the durable run exists but its replay cache does not, the stream route returns 409 and the client must use the status route.

Approval records store an `agent-thread` target with the agent, thread, resource, and original authorized principal. `createAgentApprovalResumer()` rechecks the current catalog roles, reconstructs the guarded module after eviction, and resumes as that original principal. Before resume, the wrapper rebuilds Mastra's local and global run registries from fresh trusted context. It invokes only Breakwater's reserved RBAC `processInput` hook during rehydration, then installs the complete input, LLM-request, and output processor lists for resumed loop execution. It does not replay application or policy `processInput` hooks. An authorization denial stops before registry installation, observation, or tool execution. The reviewer identity remains attached to the approval decision.
Approval records store an `agent-thread` target with the agent, thread, resource, and original authorized principal. `createAgentApprovalResumer()` re-authorizes that stored principal against the current catalog — a human against the agent's roles, an automated principal against its `allowedAutomation` declaration on the `approval.resume` entry path — reconstructs the guarded module after eviction, and resumes as that original principal. Before resume, the wrapper rebuilds Mastra's local and global run registries from fresh trusted context. It invokes only Breakwater's reserved RBAC `processInput` hook during rehydration, then installs the complete input, LLM-request, and output processor lists for resumed loop execution. It does not replay application or policy `processInput` hooks. An authorization denial stops before registry installation, observation, or tool execution. The reviewer identity remains attached to the approval decision.

## Use the lower-level durable wrapper

Expand Down Expand Up @@ -116,8 +136,8 @@ Host rules:
2. Resolve the authenticated `TenantContext`.
3. Return 404 for a foreign stored id with `requireOwnedMemoryId()`.
4. Address the thread Durable Object through `createThreadTopology()`.
5. Let the topology overwrite `x-flowsafe-tenant`, `x-flowsafe-actor`, and `x-flowsafe-role` from the resolved context.
6. Have `ThreadDurableObject` reconstruct the actor and verify the stamped tenant against its own `id.name` prefix.
5. Let the topology stamp `x-flowsafe-tenant` and `x-flowsafe-principal` from the resolved context. The principal is the sole identity channel: the retired `x-flowsafe-actor` and `x-flowsafe-role` headers are stripped on send and forward, and `createTenantResolver` refuses an inbound request that carries either. The Durable Object refuses a request that carries no principal header rather than treating the caller as a human.
6. Have `ThreadDurableObject` project the actor from the stamped principal and verify the stamped tenant against its own `id.name` prefix.

The D1 recall-path tests use one database and the same business key for two tenants. They prove isolated `recall`, `listThreads`, and working memory behavior through Mastra's own memory implementation.

Expand Down Expand Up @@ -241,6 +261,8 @@ Only connectors whose permission manifest is read-only may opt into model-reques

## Add signal providers

Provider deliveries arrive as a `service` principal on the `signal.notification` entry path. The target agent must declare that pair in `allowedAutomation`, or delivery is refused.

Core signal providers deliver through an in-process agent registry, which is not durable or tenant-aware enough for this topology. Flowsafe preserves the provider contract while routing delivery through the thread topology.

Wire:
Expand Down Expand Up @@ -281,4 +303,6 @@ Keep independent duties in separate failure boundaries. CPU termination is not a
| Notification dispatch tick | When delayed notifications are enabled |
| Provider polling alarm | Per tenant when a pollable subscription exists |

Each duty that reaches an agent carries an automated principal: the schedule tick fires as `system` on `schedule.fire`, the notification dispatch tick as `system` on `notification.dispatch`, and provider delivery as `service` on `signal.notification`. Enabling a duty is not enough — the target agent must declare that kind and entry path in `allowedAutomation`, or the run is refused at the host.

The advanced starter makes these responsibilities visible in one host. The [Deployment reference](deployment-reference.md) lists bindings and configuration, and the [Operations runbook](operations-runbook.md) covers recovery and offboarding.
2 changes: 1 addition & 1 deletion docs/flowsafe-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ D1-backed memory recall uses the salted ids through Mastra's own memory implemen

Public starts mint the thread, resource, and run ids after authentication. Status and NDJSON observation recheck the stored agent/thread/run binding and return 404 for foreign or mismatched ids. Stream replay lasts only as long as Mastra's configured cache; authoritative status remains available after replay eviction.

An agent has no public raw-resume route. Approval records persist the original authorized principal, and an approval decision resumes as that principal after rechecking the current catalog roles. The reviewer remains the actor on the approval decision event.
An agent has no public raw-resume route. Approval records persist the original authorized principal, and an approval decision resumes as that principal after re-authorizing it against the current catalog: a human principal against the agent's roles, an automated principal against its `allowedAutomation` declaration on the `approval.resume` entry path. The reviewer remains the actor on the approval decision event.

After Durable Object eviction, the in-process tool registry is gone while D1 state remains. The agent host validates the memory binding, reconstructs the guarded module, and derives fresh trusted resume context. It then rehydrates Mastra's registries by invoking only Breakwater's reserved RBAC `processInput` hook. Before installation, it restores the complete input, LLM-request, application output, and mandatory output-processor lists for resumed loop execution. Initial application and policy `processInput` hooks do not run again. The host then starts observation and resumes through `RunnerRuntime`.

Expand Down
40 changes: 10 additions & 30 deletions docs/proposals/breakwater-improvement-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,42 +305,22 @@ serialization and redaction behavior are defined.
calls in one resumed leg unless that is an explicit, audited run-scoped
policy.

### 6. Define Human, Service, and Agent Principals
### 6. Define execution principals (shipped)

#### Current state
#### Shipped implementation

`RBACMiddleware` assumes an actor with a human-style role. Scheduled work,
signals, background tasks, service-to-service execution, and agent-to-agent
delegation do not naturally have a logged-in human actor. Treating all of them
as a fabricated `operator` loses provenance and may accidentally grant human
permissions to autonomous execution.
Flowsafe defines `ExecutionPrincipal` for human, service, agent, and system execution. Human principals carry a role. Every automated principal requires `purpose`, and agent principals may also carry `delegatedBy`.

#### Improvement
Breakwater accepts the projected principal kind but does not resolve identity. Its `allowedPrincipalKinds` gate runs before role authorization and never consults human roles for an automated principal. Flowsafe's agent catalog uses `allowedAutomation` to constrain each automated kind to declared entry paths; human starts continue to use `allowedRoles`.

Before enabling automated agent entry points, define a host-level principal
model:
`trustAutomationPrincipal()` canonicalizes and freezes principals used by trusted platform entries. Audit events preserve the tenant, principal kind, principal ID, purpose, and delegation provenance. Approval decisions remain attributed to the human decider.

```ts
type Principal =
| { kind: 'human'; id: string; role: ApprovalRole; tenantId: string }
| { kind: 'service'; id: string; permissions: readonly Permission[]; tenantId: string }
| { kind: 'agent'; id: string; delegatedBy?: string; permissions: readonly Permission[]; tenantId: string }
| { kind: 'system'; id: string; purpose: string; tenantId: string };
```

Breakwater does not need to become the source of this identity. Flowsafe should
resolve the principal and project the minimum actor/permission context needed
by each gate.

#### Acceptance criteria
#### Shipped guarantees

- Scheduled or service execution never masquerades as an arbitrary human.
- Every autonomous call has tenant, principal kind, principal ID, and
delegation provenance in audit events.
- Service/agent permissions are narrower than administrative human roles by
default.
- Human approval remains attributable to the human decider even when the
requester is a service or agent.
- Agent entry and approval-maintenance audit events implemented in this phase carry tenant, principal kind, principal ID, and delegation provenance when applicable.
- Automated principals cannot derive authority from administrative human roles.
- Human approval remains attributable to the human decider even when the requester is a service or agent.

### 7. Add an End-to-End Enforcement Matrix

Expand Down Expand Up @@ -791,7 +771,7 @@ The shipped host deliberately omits a public raw-resume route. It accepts connec

### Phase B: Approval capability and principal hardening

1. Define human, service, agent, and system principals beyond the Phase A human-role snapshot.
1. ~~Define human, service, agent, and system principals beyond the Phase A human-role snapshot.~~ Shipped. `ExecutionPrincipal` carries a kind, a required `purpose` on every automated kind, and optional delegation. Breakwater's `Actor` gained `kind`, and `RBACMiddleware`/`createGuardedAgent` gate on `allowedPrincipalKinds` before roles. The agent host routes automated entry through each agent's `allowedAutomation` declaration.
2. Choose connector/leg/tool-call/input/nonce grant scope for structured grants.
3. Prove scheduled, signal, background, and nested execution cannot inherit a stale or broader grant.
4. Add dynamic principal re-resolution only when a concrete identity-provider contract exists.
Expand Down
Loading
Loading