Owner-approved scheduled policies
Design · 19 October 2026
Read the Markdown source ↗On this page
Design · 19 October 2026
A seed declares trusted server-side definitions through OA createOperatorAuth's
scheduledPolicies: () => definitions option. Only trusted Worker code calls
run; there is no HTTP run endpoint or caller-supplied actor granting scheduled
authority. Native-account adapters need an equivalent transaction fence before
adopting this door.
Definition and approval
A definition contains an id, job kind, JSON parameters, allowed actions/scopes,
and two required integer bounds: maxActionsPerRun (1–100) and
minIntervalMinutes (1–525600). Supported actions are operators:deactivate
and operators:reactivate. Scopes require non-owner roles and may restrict exact
operator ids or require inactivity for inactiveDays. Owners and invitations
cannot be targets.
Inactivity uses oa_operators.last_signin_at, retained when sessions are deleted.
An insert trigger advances it when an OA session is created. For new accounts
that have never signed in, account creation is used; both the account creation
and sign-in cutoffs must pass. This measures sign-in, not activity in the seed's
domain. Legacy accounts receive a conservative timestamp at migration time,
retaining any later known sign-in, so missing historical sessions never establish
immediate inactivity.
Canonical JSON sorts object keys, preserves array order and rejects non-JSON
values. The definition is snapshotted once; validation, action grants and SHA-256
all use that same snapshot, including both bounds. The owner
reviews that exact hash; approval rereads the definition and rejects stale hashes.
Approval stores the definition, actions, hash, owner id/authority version, session
kind, approval time and revocation details. Reapproval atomically revokes its
predecessor. When the runner first observes a changed definition, it revokes the
old approval with superseded_by_change; reverting the source cannot revive it.
Standing approval requires a native, OIDC or password owner session and the
existing requireFreshProof check, as well as origin/session transaction fences.
Development and review sessions are refused outright: cron cannot repeat their
request gate. The seed's rolePolicy must allow every declared action at approval.
Approval survives sign-out and session expiry; it is an explicit delegation.
Owner authority-version changes invalidate it, including demotion then promotion.
Scheduled authority and bounds
A scheduled handler calls auth.scheduledPolicies.run(policyId, async run => { await run.execute('operators:deactivate', operatorId) }). The callback receives a
bounded capability, never a simulated owner session. The door rereads the current
definition and live approval at admission and before each action. Before acting,
it evaluates the seed's current rolePolicy with the approving owner and target.
An internal policy actor selects an unforgeable store fence. The status mutation
and committed-action audit share a D1 transaction that rechecks approval
id/hash/actions, revocation, accepted approval session kind, active owner role and
authority version, admitted live run, action count and target scope. Sequential
capability use and the transaction count enforce maxActionsPerRun; the reference
selector also limits its candidates to that bound. Stale or overbroad selection
does not widen the approved scope. Closed run capabilities cannot act again.
Revocation stops subsequent writes at the transaction boundary; committed actions
remain committed. Existing final-owner and invitation/version triggers still apply.
Admission is serialized per policy: another admitted run cannot overlap, and the
minimum interval starts at the last admitted run's finish, including failed or
refused admitted runs. An admitted run still running beyond Cloudflare's
15-minute scheduled-handler limit
is atomically marked failed with policy_expired and a policy:expired audit
bound to its original run id. Its minimum interval is measured from admission,
so recovery cannot exceed the approved tick frequency; live runs still block
overlap. Expired capabilities cannot write or overwrite the expiry outcome.
Early ticks are refused with policy_interval. Consecutive
identical unadmitted refusals for the same policy/version/approval/outcome coalesce
into one history row with repeats occurrences and lastSeenAt. Admitted runs
retain separate rows and action history.
Runs record outcome, timestamps and refusal/error codes. Action audits bind
policy:<id>@<sha256>, approving owner, approval id, unique run id, action and
target. Refused target attempts also record the attempted action and error code.
Other auth audit values may be opaque identifiers; policy queries and the run index only extract run ids from valid JSON. Partial failures retain earlier committed-action audits. The reference handler
attempts every policy despite prior failures, and attempts recording, policies
and pending erasures independently. Durable policy AuthError refusals remain
recorded; unexpected failures aggregate only after the jobs have been attempted.
Storage failures can prevent durable history and are surfaced as failures.
Worker code and the raw DB binding remain trusted. These fences bound authority through operator-auth; they do not restrict arbitrary SQL in a compromised seed. Version a job kind when changing its semantics and put all security-relevant bounds in the hashed definition and enforcing implementation.
Adoption, history and portability
For a deployment that applied the original scheduled-policy schema, apply
scheduled-policy-hardening.sql and last-signin.sql as new numbered migrations.
When first adding policies to an older OA store, apply the current
accounts/schema/scheduled-policy.sql instead of the hardening migration, plus
last-signin.sql if the sign-in column is absent. Preserve applied migration
history. Base upgrades its original 0029 with 0030 and 0031. The hardening migration revokes existing approvals with
fresh_approval_required; owners must approve again with fresh proof. Apply the
last-sign-in migration after any oa_sessions rebuild so its trigger survives.
Fresh OA schemas include the current tables and trigger.
Declare definitions in trusted source, wire cron to run, select real candidates
and call run.execute for each. The default reference starts unapproved at
/app/policies. Its owner screen shows complete parameters/scopes, bounds, current
hash, approving owner/session kind, approval history and revocation reasons. Run
history exposes outcomes, tick occurrences, committed actions and refused targets,
including target ids, actor, approving owner, approval id, run id and timestamps.
Owners can navigate to older pages rather than losing history after a fixed cap.
Owner APIs are GET /auth/policies, GET /auth/policy-runs,
POST /auth/policy/approve with {policyId, versionHash}, and
POST /auth/policy/revoke with {approvalId}. list returns approval history and
each current definition's state. runs returns {runs, nextCursor} with up to 25
runs, newest first, including their action audits; pass ?before=<nextCursor> for
the next page. Occurrences include the first tick. Retain oa_policy_runs and
oa_auth_audit for durable history.
Portable exports preserve approval provenance but revoke approvals in the export snapshot, leaving the source installation unchanged. Import refuses an archive containing any live policy approval. Restoring records does not transfer standing authority; the destination owner must review and approve its current definitions.
const definitions = () => [{
id: 'inactive-members', kind: 'deactivate-members-without-recent-signin.v1',
parameters: { inactiveDays: 90 },
maxActionsPerRun: 25, minIntervalMinutes: 10,
actions: [{ action: 'operators:deactivate',
scope: { roles: ['member'], inactiveDays: 90 } }],
}];
const auth = createOperatorAuth({ ...authOptions, scheduledPolicies: definitions });
// Inside Worker.scheduled; select real candidates within the declared run limit.
await auth.scheduledPolicies.run('inactive-members', async run => {
for (const candidate of candidates) {
await run.execute('operators:deactivate', candidate.id);
}
});