Skip to content

release: promote dev to main (breakwater 0.7.0, flowsafe 0.9.0) - #47

Merged
gcharang merged 13 commits into
mainfrom
dev
Jul 29, 2026
Merged

release: promote dev to main (breakwater 0.7.0, flowsafe 0.9.0)#47
gcharang merged 13 commits into
mainfrom
dev

Conversation

@gcharang

Copy link
Copy Markdown
Contributor

Promotes the fully versioned dev release candidate to main.

After merge, the Release workflow publishes unpublished versions to npm in dependency order and creates tags and GitHub releases.

gcharang and others added 13 commits July 28, 2026 07:55
DOES NOT COMPILE. Committed to preserve work; flowsafe is mid-migration and
`pnpm typecheck` enumerates the remaining sites. See
.notes/plan-execution-principals.md for the full status and edit map.

breakwater (complete, 470 tests green):
- rbac/principal.ts: PrincipalKind, the shared allowlist validator. A leaf so
  assertPrincipalKinds does not become public API via the /rbac subpath.
- Actor.kind, absent meaning 'human' so existing hosts are unchanged.
- allowedPrincipalKinds on RBACMiddlewareOptions and GuardedAgentConfig,
  defaulting to ['human'] — an agent that names no automation denies every
  scheduled, signal, service, and delegated entry with zero config change.
- authorize.ts checks kind BEFORE role and skips the role allowlist entirely for
  non-humans. An automated principal carries a role only because Actor.role is
  required; consulting it would either admit whichever role the host projected,
  or force hosts to allow that role and thereby admit real humans holding it.
- Wired into both the RBACMiddleware processor and the direct-call preauthorize,
  because direct generate/stream bypass the processor chain.
- AgentAuditContext gains principalKind, principalId, purpose, delegatedBy.

flowsafe (partial):
- approval-api/principal.ts: the ExecutionPrincipal union with purpose REQUIRED
  on every automated kind, tenant-bound validation, and projection that maps
  automation to the least-privileged role so it can never satisfy DECIDER_ROLES.
- ApprovalResumeTarget.principal and AgentRunRecord (now v2) both retyped, with
  the service validator and the D1 read guard failing closed on the v1
  ApprovalActor shape. There is no upgrade path on purpose: a schedule.fire run
  stored role:'operator', so reading it back as human would launder exactly the
  authority this removes.
- AgentMeta.allowedAutomation, catalog automationAllowed, and a construction-time
  cross-check that metadata kinds match the guarded handle's allowedPrincipalKinds.
- thread-host authorize() split by kind with an AND-composed host seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Automated work no longer impersonates a person. The schedule tick, cron SLA
maintenance, signal-provider delivery, and the suspension-reconcile bridge all
minted `role: 'operator'` to satisfy the one identity the platform had. That
lost provenance and handed autonomous execution an operator's authority.

breakwater:
- Actor.kind (human|service|agent|system), absent meaning human so an existing
  host is unchanged. An unrecognized kind resolves to NO actor rather than
  falling through to the default every guarded agent already admits.
- allowedPrincipalKinds on RBACMiddleware and createGuardedAgent, defaulting to
  ['human']. An agent that names no automation now denies every scheduled,
  signal, service, and delegated entry with no config change.
- The gate checks kind BEFORE role, and does not consult the role allowlist for
  a non-human kind. An automated principal carries a role only because
  Actor.role is required; checking it would either admit whichever role the
  host projected, or force hosts to add that role to allowedRoles and thereby
  admit real humans holding it.
- Enforced at the processor gate AND the direct generate/stream gate, which
  bypasses the processor chain.

flowsafe:
- ExecutionPrincipal, with `purpose` REQUIRED on every automated kind. The
  defect being fixed is lost provenance; an optional field would let each new
  automated path skip the one thing that restores it.
- AgentMeta.allowedAutomation names kinds AND entry paths, so an agent that may
  fire on a schedule does not thereby accept signals. Absent denies everything.
  An injected host authorizer is AND-composed and can only narrow it.
- The catalog refuses a module whose declaration disagrees with its guarded
  agent's allowedPrincipalKinds, so the two halves cannot drift.
- ApprovalService.createAsPrincipal and supersedeStaleAsPrincipal replace the
  human role gate with a kind-and-tenant check for trusted platform bridges,
  rather than widening it. No principal-taking decide/claim/delegate: filing is
  platform work, deciding is human judgement.
- The principal crosses the DO boundary in its own trusted header, which
  createTenantResolver refuses on inbound requests like the others.
- statusFor falls back to the scope's PRINCIPAL, not its actor — the previous
  fallback would relabel an automated run as whoever polled its status.
- Audit correlation carries principalKind, principalId, purpose, delegatedBy.

Persisted state fails closed with no upgrade path, deliberately: AgentRunRecord
is v2 and agent-thread resume targets store an ExecutionPrincipal. A v1 record
cannot be upgraded honestly, because a schedule.fire run stored role:'operator'
and reading it back as a human would launder exactly the authority this
removes. A suspended pre-upgrade agent run cannot resume.

2226 tests pass; the workerd spike verifies restart, approval resume, and the
cross-tenant and forgery negatives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three review lanes independently found the same blocker: THREAD_PRINCIPAL_HEADER
was defined, read by the thread DO, and refused inbound — but stamped by nothing.
createThreadTopology.send/forward set only the tenant, actor, and role headers,
so every automated caller was rebuilt inside the DO as a human carrying the
projected 'viewer' role, which no start gate admits.

The whole feature was therefore unreachable, and three paths that worked before
this branch — scheduled agent fires, signal-provider delivery, cron notification
dispatch — failed 403 for the wrong reason. The suite stayed green because every
automation test builds a ThreadScope in-process and never crosses that hop.

- Stamp the principal in both send and forward.
- The DO now REFUSES a request carrying no principal header instead of defaulting
  to a human, and cross-checks the decoded principal against the actor and role
  headers so the two channels cannot disagree.
- decodeExecutionPrincipal picks fields explicitly rather than spreading, so an
  attacker-supplied extra property cannot ride into DO storage or D1.
- boundedText rejects header control characters, since these fields now serialize
  into a header.
- StartIdleRunInput carries the principal, not an ApprovalActor. The starter and
  spike were downgrading it with humanPrincipal(), laundering automation into a
  person on every idle wake.

Also from the lanes:

- #authorizeAutomated validated a three-field subset, so createAsPrincipal
  accepted an automated principal with NO purpose — a hole in the one invariant
  the module exists to enforce. It now validates the whole principal.
- Both trusted service entries now attach principalAuditFields, so the two paths
  that most need attribution stop auditing as a bare 'viewer'.
- samePrincipal is cast-free; delegatedBy?: never on service/system makes a wrong
  shape a compile error rather than a runtime-only rejection.
- breakwaterActorFor is the single source for the role projection, which was
  written twice with two copies of its rationale.
- authorizeActor requires allowedPrincipalKinds; its default was unreachable and
  was a second place the human-only policy could drift.
- Dropped isAutomatedPrincipal, an unused export on a published barrel.

Comments that claimed protections the code did not provide are corrected: the
principal header's "absent means human is stricter" argument was self-
contradicting (the human branch is more permissive for an agent that declares no
automation), and contract.ts asserted a one-way layering rule that thread-do.ts
no longer honours.

New coverage for the gaps the lanes named:
- A topology->DO round trip asserting an automated principal arrives as
  automation, a human arrives unchanged, a forged client principal is
  overwritten, and a request bypassing the minter is refused.
- v1 AgentRunRecord, v2-with-ApprovalActor, and purpose-less principals all
  rejected; the same two shapes rejected on the D1 resume_target read path.
- A cross-package tripwire pinning EXECUTION_PRINCIPAL_KINDS to breakwater's
  PRINCIPAL_KINDS — the comment claimed one existed and none did.
- AUTOMATED_PROJECTED_ROLE pinned out of RUN_START_ROLES and DECIDER_ROLES; that
  invariant is what makes projecting an inert role safe.
- The spike now runs /sched/agent, proving a SYSTEM principal reaches a guarded
  agent through the real Worker->DO hop on workerd.

2236 tests pass; SPIKE VERIFIED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the four review decisions.

The service's automated entries authorized on the caller's own assertion that it
was a robot: `kind !== 'human' && tenantId === store.tenantId`, all satisfiable
by an object literal. Automation therefore had strictly more create authority
than a human `viewer`. TrustedAutomationPrincipal now carries a `unique symbol`
brand, minted only by `trustAutomationPrincipal`, which refuses humans and
malformed principals. Following the TENANT_BOUND idiom already used for
SystemApprovalStore, with the same accepted residual: forging requires importing
the symbol and stamping it, a deliberate TCB bypass on par with a cast. What it
removes is accidental structural satisfaction, and the rushed fix that hands a
request-derived principal to a trusted entry.

The bridge REQUIRES the brand, so vouching happens at each host's TCB boundary
(maintenancePrincipal, buildHostApprovalService, run-router, thread-host,
reconcileApprovalsOnStatus) rather than deep inside shared code. #authorizeAutomated
is now a tenant check only — the kind half is carried by the type.

Layering: principal.ts stays in approval-api and the corrected contract.ts
comment stands, but import-isolation.test.ts now pins which approval-api leaves
do-runner may reach, so that one accepted edge cannot widen silently. types.ts
appears in the pin because contract.ts reaches it with a type-only import that
erases at runtime; the comment says so.

Public surface: removed ExecutionPrincipalError (assertExecutionPrincipal throws
a plain Error) and rejectReservedAgentContext, which was exported but never
called anywhere — every real path uses the silent-strip sanitizeStoredAgentContext.
Kept assertExecutionPrincipal and EXECUTION_PRINCIPAL_KINDS: the first is the
validating minter, the second backs the cross-package tripwire.

2236 tests pass; SPIKE VERIFIED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The re-review found the inverse of the first blocker. The gate was reachable,
but the starter declared only system+schedule.fire while wiring three more
automated entries against that same agent — so the reference host, which is what
consumers copy, silently denied its own features:

- provider delivery      service / signal.notification    -> 403
- notification dispatch   system / notification.dispatch   -> 403
- approval resume         system / approval.resume         -> threw

The third was the worst: a scheduled run that suspends on the starter's
approval-gated connector — its entire advertised purpose — filed an approval, a
human approved it, and the resume threw. Decided approval, stranded run.

'approval.resume' is now IMPLIED by the kind that started the run, and declaring
it is rejected. Resuming continues a run this kind was already admitted to
start, so it asks whether the kind may still drive the agent at all — not
whether a second entry path was remembered. The narrowing that matters still
holds: a kind removed from the declaration entirely can no longer resume.
Starter and spike now declare every entry they actually wire.

Also from the lanes:

- The topology derives BOTH wire identities from the principal and asserts the
  principal's tenant matches the context's. encodeExecutionPrincipal omits
  tenantId and the DO re-binds it, so a context whose principal belonged to
  another tenant was silently re-tenanted and the audit named the wrong one.
  Deriving the actor headers from the principal also removes a class where a
  host that set actor and principal inconsistently got a hard 403 at the far end
  of a DO hop, reported as an identity attack rather than the wiring bug it was.
  This gives assertExecutionPrincipal the call site it lacked.
- systemTenant took a purpose: it drives both the schedule and dispatch ticks, so
  every dispatch audit row claimed to be a scheduled agent execution. purpose is
  required precisely so provenance stops being fabricated.
- Dropped tenantForActor from the starter: unreferenced, and its body was the
  human-downgrade shape the earlier round removed everywhere else.
- Dropped the unread actor param from handleNotificationDispatch.
- Deleted a stale docblock that claimed #authorizeAutomated refuses humans at
  runtime; the sibling block correctly says the type carries that half.
- boundedText's comment claimed to prevent header injection. JSON.stringify
  already escapes control characters; the real reasons are that id also travels
  raw in x-flowsafe-actor and that refusing early beats a Headers.set TypeError.
- The import-isolation pin is file-level and cannot see import KIND; it now says
  so instead of implying it enforces type-only-ness.

Docs: allowedAutomation was the most consequential field in the release and
appeared in no guide. durable-agents.md now documents the deny-by-default
opt-in, the handle/metadata agreement, why approval.resume is never declared,
and names the principal kind and entry path each scheduled duty arrives on.

The spike gains the deny direction (D-S4): the same SYSTEM principal on an
undeclared entry path is refused through the real hop, with a generic 403 that
leaks no policy detail. A gate proven only to admit is how an unreachable gate
looked healthy for a whole branch.

2236 tests pass; SPIKE VERIFIED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review recommendations, both accepted.

The bridge took a pre-branded principal, so the showcase hand-minted one
byte-identical to what buildHostApprovalService built internally — a host
performing a TCB trust assertion for a value the library already knows how to
construct, in the sample every consumer copies. queueApprovalForSuspension,
reconcileApprovalsForSummary, and resumeRunWithRequeue now take a systemActorId
and mint their own bookkeeping principal against the service's tenant binding.
ApprovalService exposes tenantId for that. trustAutomationPrincipal stays
exported for genuinely host-specific automated principals.

x-flowsafe-actor and x-flowsafe-role are retired. After the principal landed,
the DO no longer built identity from them — it projected scope.actor from the
principal and kept the two older headers only to compare back against it, a
self-consistency check on values the same topology call stamped from the same
object. That redundancy bought nothing and cost a failure class: a host whose
TenantContext.actor disagreed with its principal got a hard 403 at the far end
of a DO hop, reported as an identity attack rather than the construction bug it
was. It also hid the tenant gap fixed in the previous commit, because a single
field cannot disagree with itself.

Gone with them: the actor/role extraction and its two ThreadIdentityError
branches, the cross-check, and THREAD_ACTOR_ROLES — a hand-copy of
APPROVAL_ROLES that existed only to validate a header nobody reads now.

The topology strips both names on send AND forward, so a caller's forged value
cannot ride into the DO as if the topology had stamped it, and
createTenantResolver still refuses them inbound: a client sending them expects
an older flowsafe, and a mixed-version deployment should fail loudly rather than
have the header quietly ignored.

Test count moves 2236 -> 2234: two thread-do cases asserted refusal of a missing
actor id and an invalid actor role, and their subject no longer exists. The
refusals they guarded are covered by the missing- and invalid-principal-header
cases.

2234 tests pass; SPIKE VERIFIED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's "Verify packed flowsafe agent-host surface" step failed: the packed-surface
test asserted the breakwater peer range against a hardcoded '>=0.6.0 <1.0.0',
and this branch moves the floor to 0.7.0 because allowedPrincipalKinds ships in
that release.

Compare against the source manifest instead of a copy of its value. This script
runs only as its own CI step, never under `pnpm test`, so a hardcoded range goes
stale the moment the floor moves and fails only after the change is pushed —
the third time today a value duplicated outside the default test run bit us.
A shape assertion stays, so the range cannot silently become unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three pre-merge gaps in the execution-principals work.

trustAutomationPrincipal validated a principal and returned the caller's
own object, so a vouched `system` principal could be rewritten into
`{kind:'human', role:'admin'}` before #authorizeAutomated re-read `kind`.
Reproduced: sameObject=true, brand=null, frozen=false.

The root cause is validate-then-reread, not the missing clone: any second
read of a caller's object can disagree with the first. An own accessor
survives Object.freeze; a prototype getter and a Proxy `get` trap are
invisible to a Reflect.ownKeys shape check; and getOwnPropertyDescriptor
is itself a trap over an extensible target. canonicalPrincipal now reads
every field exactly once as an own data property and returns a fresh
plain snapshot. trustAutomationPrincipal brands and freezes THAT, and
assertExecutionPrincipal returns it, so validated and used are the same
values and there is no second read left to disagree. The trusted entries
recheck brand, shape, kind, and frozen rather than an erased parameter
type; ExecutionPrincipal fields are readonly.

Automated audit provenance stopped at the primary event. principalAuditFields
now rides the tenant-mismatch denial, the create notify/stream failures, and
the supersede stream failure, and the re-queue bridge mints the platform's
own bookkeeping principal instead of hand-shaping a viewer actor. It mints
inside the try: a blank systemActorId fails the vouch for the same reason it
fails the re-queue, and hoisting the mint lost the only event that names the
suspended step paths. Without a principal the event still fires, unattributed.

Public docs described the retired contract: the actor/role headers as live,
resume as a role-only recheck, and no mention of allowedPrincipalKinds or
allowedAutomation in the breakwater and starter READMEs. Roadmap Phase B
item 1 is marked shipped and the threat model gains the new rows.

Also closes the same class where the report did not look: sweepSLA accepted
a human principal, which would have stamped principalKind:'human' onto every
tenant's cron escalations. It is refused, and AutomatedExecutionPrincipal
narrows the boundary so a human is a compile error. TRUSTED_AUTOMATION leaves
the public barrel, and a trustAutomationPrincipal call in the thread host that
asserted trust nothing consumed is gone, so the greppable invariant still means
something.

Every runtime guard is mutation-tested: removing any one turns tests red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
anchorage-showcase f304782 Jul 29 2026, 06:41 AM

@gcharang
gcharang merged commit 3a80d88 into main Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant