> For the complete documentation index, see [llms.txt](https://sparkwing.dev/llms.txt). This page as HTML: https://sparkwing.dev/docs/auth/

# Authentication + authorization

A connection chooses the controller. A credential identifies the caller and its
exact authority. A claim confines execution to the work the controller awarded.

The controller enforces the shared capability policy through store-backed
authentication and transactional authorization. The production constructor wires
root provisioning, credential inventory and rotation, and OIDC identity reads
to concrete store implementations. See
[Credential management](credentials.md) for the CLI surface.

## Cloud operations and test environments

An operator can create a team and its first administrator without OAuth:

```bash
sparkwing operator teams create --team support-test --display-name Support --profile deployment -o json
```

The profile must present a deployment credential with `operator.tokens`.
The matching HTTP operation is `POST /api/v1/operator/teams`, with
`{"slug":"support-test","display_name":"Support test"}`. A configured
operator's direct browser session may also call it with its CSRF proof.

One transaction creates the team, a dedicated account without a provider
identity, its owner membership, the first team-admin capability and a
`team.created` audit record naming the operator. The credential has the owner's
team permissions and no deployment permissions. Its membership and revocation
remain live authorization checks. Like every token holding `team.admin`, it
expires after 30 days, and rotation keeps that expiry. Use it to invite a real
owner, who signs in with their own identity, before the 30 days end. Revoke it
through the team token commands.

JSON returns `team`, `account_id`, `capability`, `prefix` and `grants`.
Plain and pretty output print the capability once. Store that credential in
the recipient's private connection profile. The server persists its digest,
not the bearer. The operation refuses an existing or retired team slug with
`conflict` and makes no changes. It never retries automatically because a lost
response cannot recover the one-time credential.


Use `sparkwing operator teams show --team support-test --job-namespace sparkwing-cloud-jobs --profile deployment -o json`
to read the team identity and its protected node label key and value. The value
hashes the launcher's Cloud Job namespace with the team slug. The launcher sets
that exact team value when it reserves a never-used node, together with permanent
used-team and used-run fences. It never moves a used node to another team or run.
Jobs of the same run may share the node.

Cloud capacity uses one shared NodePool per hardware class and deployment. Its
template identifies the class and Cloud Job namespace and carries the generic
`sparkwing.dev/dedicated=true:NoSchedule` taint. The launcher verifies the node,
NodeClaim and shared NodePool provenance before binding the team. It has read-only
NodePool `get` and `list` access and creates no pools. Team creation requires no
infrastructure change. Without a shared pool for the requested class, a held
award ends as `cloud_capacity_unconfigured`, releases its unstarted credit hold,
and gives the customer support@sparkwing.dev and the run ID as a support reference.
A closed Cloud budget breaker ends such an award the same way as
`cloud_capacity_unavailable` (see [deployment](deployment.md)).
Scaling or exhausted shared pools keep the award pending, with “Waiting for
dedicated Cloud capacity” and elapsed time in run status.

The underlying `GET /api/v1/operator/teams/{team}` returns team ID and display
name to deployment credentials. Financial and console details remain restricted
to console sessions.

This is the Cloud operations, support and test-environment path. Local mode
uses its default team and OS-verified socket. Self-hosted controller onboarding
is outside the launch offering.

## Controller authorization

Embedded controllers supply `WithMetricWriter` for job metric and usage writes,
and `WithOperatorSettingsWriter` for deployment setting changes. Each writer
receives the verified authority and resource target and rechecks them in its
committing transaction. Signup mode and limit changes commit together. Missing
writers return `policy_missing`.

Job metrics use `GET` and `POST /api/v1/runs/{run}/jobs/{job}/metrics`. Process
usage uses `POST /api/v1/runs/{run}/jobs/{job}/usage`. The former `/nodes/` paths
are removed. Request budgets run after policy authorization and use the
credential's team and authenticated holder. A successful empty claim response
spends the polling budget. An award does not. Log concurrency refusals use the
canonical error envelope with `rate_limited` and a reason in `error.details`.


The controller's shared policy routes use the store's authentication snapshot
and binding checker by default. Each request reads current capability ancestry
and account membership. Browser writes use the session-bound CSRF verifier by default. The header
and cookie must match the token derived from the persisted encrypted key, and
the request must come from the same origin. A forged or cross-session token
returns `forbidden`. Session key failures return `unavailable`. Configured operator
accounts use the default console-session adapter. Their direct account sessions
receive `operator_console: true` from `/api/v1/me`, which shows the Operator
navigation link even while waitlisted. Operator console routes bypass the
customer waitlist screen. Customer pages still require admission, and console
API requests still check the configured operator account and direct session.
Verified local peers use
`WithPeerIdentity`, whose callback returns only the transport-verified uid or SID
as a local subject. Request headers and loopback addresses supply neither
identity. The local store must implement peer authentication snapshots and
transactional authority checks before those presentations can execute work.

Embedded local controllers use `Server.HTTP()` for the API and dashboard
handlers in `wingd.LocalHTTP`. The API reads the verified socket identity from
Wingd's connection context. Explicit credentials keep precedence. Browser
requests supply their own authentication. These handlers expose the same capability policy routes as the network controller.

Schedule, invitation, build-trust, billing and storage reads use member
capability policy. The same holds for GitHub App installation, repository,
trigger and extra-repository reads. Schedule and review mutations pass the verified
`Authorized` value to tenant transactions through `WithScheduleWriter` and
`WithRunControlWriter`. Each committing transaction rechecks ancestry,
membership and the selected route alternative. Team administration, run deletion and GitHub App configuration writes use the same boundary. Embedded controllers supply `WithTeamWriter`, `WithGitHubAppWriter` and `WithBillingWriter` for committing transactions. The token-table verifier, its caches and its route fences are removed. Missing adapters fail
with `policy_missing`. Metrics on the API listener uses deployment or console
policy.

Embedded store users authenticate through capability snapshots and use the
tenant or operator capability inventory and mutation methods. The unused
token-table authentication, listing, lookup and revocation APIs are removed.
Here runs use sealed admission and capability issuance.

GitHub App connection pages verify their signed browser flow through the shared
policy router. Connecting, listing administered installations, selecting and
completing a connection require a live account session with `team.admin` in its
active team. Form posts require the session's CSRF token. Provider returns
verify signed state and the browser verifier. Embedded controllers supply
`WithGitHubConnectWriter` for the linked-identity read, single-use state
consumption and installation binding. Each write rechecks the session and
current team ownership. The final bind also rechecks the linked GitHub identity.

Console mutations pass their verified authority to `WithConsoleWriter`.
The transaction derives the audit actor and rechecks the direct operator session.
This covers credit grants, billing trust, standing-request declines, holds,
account/team deletion, waitlist approval, credit settings and installation
unbinding. Waitlist approval requires
an `Idempotency-Key` header. A missing transaction adapter returns `policy_missing`.

Reporting reads use the shared capability policy and the authorized resource's team. This includes run lists, receipts, attempts, steps, events, approvals, debug pauses, pipeline lists and latest runs, trends, runner views, sizing and logs. Unbound machine roots and deployment credentials cannot substitute for team membership. Work and trigger claims receive only the read alternatives declared for their bindings.

Job logs use `/api/v1/runs/{run}/jobs/{job}/logs`, with `/stream` and `/completeness` beneath it. The old `/runs/{run}/logs/{job}` paths are removed. Use `/api/v1/runners` instead of `/api/v1/agents`, `/api/v1/approvals` instead of `/api/v1/approvals/pending`, and `/api/v1/sizing` or `/api/v1/sizing/explain` instead of `/api/v1/capacity/profiles`. Read debug pauses at `/runs/{run}/debug-pauses`. The `paused` alias is removed.

The default tenant adapters recheck secret and git-credential writes in their
committing transactions. They use current team-admin membership and the store
clock. Secret reads select the pipeline from the claim's bound run, check its
sealed declarations and reserved pools, and commit a release audit before
returning the stored envelope. Omitting the run selector does not remove the
claim's binding. Members may read only unmasked values in their own team.

`GET /api/v1/queue` uses the shared capability policy. A member or verified
local peer needs `runs.read` for its active team. Queue groups and advertised
runner headroom belong to that team. Machine roots and work claims cannot read
the team queue. The former `/api/v1/queue/state` path is removed. The local
controller that `sparkwing dashboard start` runs also reports its machine's
admission daemon: `daemon_version`, `daemon_uptime_ms`, the `cores` and
`memory` rows and the daemon's holders and waiters. Without a running daemon
those fields are absent, and a daemon that does not answer returns
`unavailable`. A team or Cloud controller has no daemon to report.

Run intake, plan acceptance, job status reads, job transitions, finish,
cancellation, retry and run/job renewal use the shared capability policy. The
controller resolves the resource's team before authorization and derives
actors and fences from the credential. Unknown or ended credentials get
`unauthenticated`. A live
credential without the grant or binding gets `forbidden`.

Create a run with `POST /api/v1/runs`, submit its plan to
`POST /api/v1/runs/{run}/plan`, and report job state through
`POST /api/v1/runs/{run}/jobs/{job}/transition`. Renew the awarded claim through
`POST /api/v1/claims/{claim}/heartbeat`. Outputs use the authorized
`/api/v1/data` upload, commit and record operations. The controller derives
execution fences from the authenticated claim.

Job-output reads authenticate the consuming job's live award, then authorize
the producer against the consumer's declared dependencies in the same run and
team. An in-run output read cannot select an unrelated job or another run.

An absent route returns the shared `not_found` error before credential
lookup. A method with no handler on a registered path returns
`method_not_allowed` and `Allow`, also before credential lookup. Neither
response renews browser sessions.

`GET /api/v1/runs/{run}/jobs` returns a status array from one committed
snapshot. It accepts member `runs.read` or the live run owner's `trigger.host`.
`GET /api/v1/runs/{run}/jobs/{job}` also accepts that job's live `data.read`
authority. An executor cannot list siblings or read another job through this
route. Both responses use the shared safe job status fields. They omit job
declarations, environment, claim identities and execution settings. The old
`GET /nodes` and `GET /nodes/{nodeID}` paths are removed.

`POST /api/v1/runs/{run}/jobs/{job}/cancel` accepts `runs.control` from a
member or `trigger.host` from the live owner of that run. The state transaction
requests cancellation of held jobs and preserves the holder's result rights.
The first cancellation reason is recorded separately from the holder's
terminal result and reaches the holder on renewal.

`GET /api/v1/runs/{run}/jobs/{job}/failure?generation=N` requires `data.read`
and a declared recovery read edge to that source generation. It returns the
persisted masked job failure, including its stage, or `null` when none was
recorded. Status text is never converted into a synthetic failure. Missing,
duplicate or nonpositive generation selectors return `bad_request` after
credential authentication.

`GET /api/v1/runs/{run}/snapshot` requires a live trigger owner with
`trigger.host` bound to that run. It returns one committed scheduling view,
including generated jobs and empty generator groups. Ordinary members and
work or plan claims cannot read this private owner view. Cancellation and
owner lease loss refuse new reads. A cancellation committed after
authentication appears in the returned snapshot. Accepted expansion bodies,
hashes and source generations travel in the same committed read. Replayed
expansion responses retain each generated job's current state and attempt.

Pipeline sizing uses `/api/v1/pipelines/{name}/sizing`. Members need
`runs.read`. Executable claims need `source.read` and the bound run's exact
sealed repository and pipeline. Bare runner and Actions roots cannot read
sizing. Bootstrap credentials can acquire source but cannot read sizing.
The `job` query selects one job. Omitting it selects the pipeline rollup.
An authorized missing measurement returns `not_found`.

Pins, observations, contention and waits use the `pin`, `observations`,
`contention` and `waits` suffixes on that path. They require `node.report`
and the same pipeline binding. Cancellation permits reporting while the
claim remains live. Member and admin grants cannot bypass this binding.
Writes require an authority-bearing sizing transaction adapter. Without one,
the controller returns `policy_missing` before storing a measurement.
The old `/profile` paths are removed.

Token revocation and member updates use the same policy router. A team token is
revoked at `DELETE /api/v1/tokens/{token}`. Deployment service/operator roots use
`DELETE /api/v1/operator/tokens/{token}` with `operator.tokens` or an operator
console session. Readers and editors may revoke tokens they own or created.
Console credential operations recheck the direct session, lifetime and current
operator configuration in their committing transaction. Team routes derive their active team from the committed
credential. Header and query hints cannot select another team.

`GET /api/v1/team/members` returns the active team's member directory and
requires `runs.read`. An empty team returns `[]`.

`GET /api/v1/credits` and `GET /api/v1/credits/history` require a live member
or verified local peer with `runs.read`. The committed credential selects the
team. Machine roots, executable claims and bootstrap credentials cannot read
the team ledger. Both routes use the common error envelope and authenticate
before checking whether credit billing is enabled. An authorized caller gets
`not_found` when billing is unavailable. History retains the `limit` query
and returns empty grants and charges as arrays.

`PATCH /api/v1/team/members/{account}` accepts a role of `owner`, `editor` or
`reader`. `DELETE` on that path removes the membership; it needs `team.admin`,
except that a member may remove themselves from a signed-in session (not with a
token). The store rechecks the
authenticated authority and commits the role change or removal in the same
transaction. Removal ends the member's existing authority for that team,
including delegated data credentials. Rejoining does not reactivate it; sign in
again to use the new membership. A missing member returns `not_found`. A last-owner change returns
`conflict`. Browser mutations require CSRF proof. Revocation stops descendants'
new controller operations, including those parented to a session. Previously
issued external credentials and signed URLs keep their documented lifetimes.

`POST /api/v1/runs` accepts a `request` and `idempotency_key`. The request names
its team. A bound job creating a child must name its own run and job as the
parent. Intake returns the committed run number and first seal. A here bearer
is returned once. Replaying intake returns the committed response without
another bearer.

`POST /api/v1/runs/{run}/plan` accepts `declarations`, `resolved` and `plan`.
The store validates the second seal against the persisted first seal, including
a retry's preserved source seal. Raw plan snapshots are no longer accepted.
Plan, job-transition and finish requests from ended claims require the exact
recorded body, actor and fence to replay. Mutations recheck authority inside
the store transaction. A lost fence gets `lock_held`.

Job transitions use `/api/v1/runs/{run}/jobs/{job}/transition`. Finish ends the
run and its owner unit together. Published local runs use a separate transition
edge and receive no executable owner. Cancellation carries a reason. Retry
carries an idempotency key and leaves source seals unchanged. Run/job heartbeat
responses carry the committed lease expiry, cancellation and watched jobs.
Every request uses JSON. Bodies cannot replace authority, holders or fences.

Job and step annotations, summaries and step transitions use the same policy
under `/api/v1/runs/{run}/jobs/{job}`. A job claim reports only its own job.
A trigger owner may report its run's jobs, subject to the transaction's holder
check. A local publisher uses its separate run-bound publication authority.
Cancellation permits reporting while the claim remains live. An ended claim
cannot write these reports.

Step reports carry a positive `source_attempt`, `step_id`, state and an
optional masked inner error. The controller derives the actor and claim fence
from authorization. The store owns stale-attempt refusal and committed timestamps. Annotations and summaries accept at most
128 KiB of masked text. The old `/nodes/` reporting paths are removed.

## Data read authority

Download signing and batched presence use the shared capability policy.
A live claim needs its data grant and sealed run/repository/ref binding.
A repository reader needs existing repository authority. Cache grants,
ordinary member tokens and machine roots cannot substitute for either.
Dependency-cache lookup requires `data.read` on a job credential or a
repository cache reader. An editor or owner with repository authority can
mint the repository reader through `POST /api/v1/tokens` with a
`remote_cache` data scope. Refusals name this requirement without reporting
whether the requested cache exists.
Each operation resolves current authority again and checks the read set
before reading metadata. Presence queries omit unreadable digests. Download
signing applies the team's daily byte cap and returns a 60-second URL for
one immutable object. The returned storage headers contain no controller bearer.

## Credential discovery

`GET /api/v1/auth/whoami` uses the shared policy verifier. It returns the
committed subject ID as `principal`, the subject kind as `kind`, and grants
as `scopes`. `team` is present when the credential has an active team.
`token_prefix` names a capability. Sessions and verified local peers omit it.
The response excludes session IDs, ancestry, claim fences and billing facts.
An empty grant set returns `"scopes":[]`.

`GET /api/v1/services` and `GET /api/v1/data/capabilities` use the same verifier
and require an active team. They announce configured services and data transfer
support. Availability is checked after authorization. An authorized caller gets
`not_found` when nothing is configured. Missing or dead credentials receive
`unauthenticated` regardless of service configuration. Bootstrap and eval
credentials cannot use these discovery routes or `whoami`.

All three routes return `Cache-Control: no-store`. Unsupported methods return
`method_not_allowed` with `Allow: GET`. Identity lookup requires an explicit
credential or a verified local peer. An address, query parameter or identity
header cannot supply authority.

The OIDC discovery document at `/.well-known/openid-configuration` and public
key set at `/.well-known/jwks.json` also use the policy router. Providers can
read them without a Sparkwing credential. Both retain their one-hour public
cache header. A controller without an issuer returns `not_found`, and
unsupported methods return `method_not_allowed` with `Allow: GET`.

## Generator expansion authority

`POST /api/v1/runs/{run}/jobs/{job}/expansion` uses the shared capability
policy. It requires `expansion.submit`, the bound source job and generation,
and a live owner or eval lease. Eval of a controller-dispatched run also
requires that the controller still owns the run. The ended source claim cannot submit an expansion.

The body supplies `source_generation` and exactly one of `body` or `failure`.
A successful empty expansion uses `"body":{"jobs":[]}`. A failure uses the
shared inner error object with `code` and `message`. The controller derives
the actor, holder and lease generation from authorization. Success and failure
reach the same store transaction, which rechecks authority before acceptance
and owns replay. Unsupported methods return a JSON `method_not_allowed` error
with `Allow: POST`.

## Capability inventory and rotation

All runner enrollment requires an independent account session. Shared runners
require current team administration for enrollment, token
rotation and revocation. Members enroll their own personal machines through
`sparkwing runners add --personal --session-stdin` with an independent account
session. After fetching the session CSRF proof, the CLI attempts logout on
enrollment success or failure. Use a dedicated session because logout ends it
for every client. The team setting `members_can_add_machines` defaults to true. When it
is false, personal enrollment requires administration too. A personal runner
records the membership epoch, grant and setting that authorized enrollment.
Every use checks them again, and turning the setting off ends members' personal
runners for good: turning it back on does not revive them. Removing and re-adding the member cannot revive
an old credential. Shared runners belong to the team and survive the
registrar's demotion or removal. Revoke a departing registrar's shared runners if they still control the machines or bearer credentials.

Member tokens default to and expire within 90 days. They retain their reported
expiry when the minting session reaches its idle or absolute timeout. Logout,
admin revocation, membership removal or demotion ends them immediately.
Rejoining or promotion cannot revive them. `team.admin` requires
`--allow-team-admin` (`allow_team_admin` in the API) and expires within 30 days.
Omitted expiry uses the applicable ceiling. A requested expiry beyond that
ceiling is refused, including during rotation. The refusal names the applicable
90-day or 30-day maximum and asks for a future expiry within it.
Invitation acceptance checks that the inviter still holds team administration,
and that the account owns the invited address: the last sign-in to verify an
address, through any account, makes that account its one owner. Unlinking the
account's last sign-in that verifies an address, or that sign-in's provider
reporting another address, stops the account seeing or accepting invitations
to it.

Teams can have any number of members, regardless of their funding or payment
holds. Invitation creation and acceptance do not depend on member count.

`GET /api/v1/team/settings` returns `members_can_add_machines`,
`claim_ranking`, `max_children_per_root` and `high_trust` under `runs.read`. `PUT /api/v1/team/settings` requires
`team.admin` and names at least one of them. `claim_ranking` takes distinct
tiers from `here`, `personal`, `github-actions`, `self-hosted` and `cloud`, and
an empty list means first come. A self-hosted controller has no Cloud launcher
and refuses `cloud` with 400. `["default"]` restores the default ranking. Changes and their actor, store time and old/new
values commit with an audit event.

Administrators can use
the console's team settings or `sparkwing team settings set --profile work
--members-can-add-machines false`. Members can read it with `team settings show`.

`GET /api/v1/tokens` lists credentials within the authenticated team. Members
need `runs.read` and see their own credentials. A team admin can see the team's
credentials. `GET /api/v1/tokens/{token}` checks ownership of that prefix.
Deployment service and operator credentials use the corresponding collection
and detail routes under `/api/v1/operator/tokens`, requiring `operator.tokens`
or an operator console session.

The collection returns `{tokens:[...]}`. Each row carries `prefix`, `team`,
`subject`, `audience`, `grants`, `created_by`, `issued_at`,
`metered`, and optional `expires_at`, `last_used_at` and `revoked_at`. Times are RFC3339
timestamps. The response omits bearer secrets, digests, bindings and ancestry.
`kind` filters by `account`, `machine`, `service` or `operator`.
`include_revoked=1` includes revoked rows. Duplicate or invalid filters fail.

`POST /api/v1/tokens/{token}/rotate` replaces an owned credential. Only the
owner rotates an account token or a personal machine: a team admin who rotates
another member's token gets 403 and the token keeps working, and revokes it
instead. A team admin rotates shared runners. Deployment
credentials use `/api/v1/operator/tokens/{token}/rotate`. The body accepts
`grace_secs` and optional `expires_at`. Grace defaults to 24 hours and accepts
zero through seven days. An omitted expiry keeps the original expiry. An
explicit expiry must pass the original parent's ceilings or the current root
origin policy. The transaction preserves the original
authority and ends the old credential after the grace window. Explicit
revocation ends that window at once.

The rotation response returns `capability` once, `new` public metadata,
`old_prefix` and the RFC3339 `old_revoked_at`. Do not replay a rotation after
losing its response. Browser mutations require session-bound CSRF proof.

A replacement retains the credential's owner, registration, scope and original
parent. A predecessor can be rotated once. Failed rotation leaves the
credential unchanged.

All inventory and rotation responses use `Cache-Control: no-store` and the
common error envelope. The controller uses its store manager by default. Embedded
controllers can supply `WithCapabilityManager` for their own persistence.

## Browser session lifecycle

`GET /api/v1/auth/session` accepts a live account session from one session
cookie or one `Authorization: Session <id>` header. An Authorization header
takes precedence over cookies. Duplicate headers, duplicate session cookies,
unknown or ended sessions and HEAD are refused. The response includes the
current role, narrowed scopes, session-bound CSRF token and renewed expiry.
Renewal cannot extend the absolute session lifetime. A disabled account-session
license or failed proof cannot renew the session.

`POST /api/v1/auth/logout` takes no body. It ends only the presented session,
requires the session's CSRF header and cookie plus same-origin proof, and
clears the browser cookies on success. A body or query parameter cannot select
another session. Both endpoints return `Cache-Control: no-store` and the
canonical error envelope. Session-key and deletion failures return `unavailable`.

Other policy routes accept the same Session header or one session cookie and
check the configured absolute lifetime without renewing. A malformed header
cannot fall back to a cookie. Dashboard requests reach that proof boundary
before session renewal, so a failed CSRF check cannot extend a live session.

The browser form posts to `POST /logout`. It requires a live session and a
`csrf_token` field matching both the CSRF cookie and the stored session, plus
same-origin proof. It deletes only that session, clears its browser cookies and
redirects to `/login`. It never renews before checking proof. Refusals use the
canonical error envelope. An unavailable session returns `401`; a key or
deletion failure returns `503` and leaves the cookies in place.

A session renewal extends expiry by seven days, but never past 30 days from
creation. Ordinary policy reads and writes check that lifetime without
renewing. The renewal endpoint refuses an over-age session and retains its row
while member tokens depend on its revocation state.
Future-created sessions are refused before renewal and remain unchanged.
An embedder changes the cap with
`controller.Server.WithSessionMaxLifetime`.

Token sign-in ends the browser's prior session unless that session is an ancestor
of the submitted token. The minting session is retained because revoking it would
also end the token and the new session. Explicit logout of the minting session
ends its tokens and descendant sessions. Logging out a descendant ends only
that descendant's authority.

<!-- design:begin -->

## Token format

Controller capabilities start with `swk_`. Node-host capabilities start with
`swn_`. Each contains 256 random bits. The prefix selects the audience. It does
not identify a human, runner or administrator. The issuer records the subject,
team, exact grants, binding and lifetime.

The lookup key is SHA-256 of the complete reference token. Metadata carries a
16-character public `prefix` for selection and audit. Creation and rotation return the raw
bearer once. Metadata lists contain no bearer.

Credentials travel in memory, a private file read by the process, or the private
protocol pipe. Runner and controller processes take a credentials directory.
A job receives a single-use bootstrap file. Credentials never travel in
process environment variables. The node-host hello sends its process-lifetime
credential over the protocol pipe.

A connection profile belongs in the user's private machine file. A project file
cannot choose a controller connection or capture its credential.

## Scopes

Grants are exact names. There is no wildcard grant and no `admin` implication.
Each route declares its accepted credential classes, grants, binding and
liveness checks. A deployment credential cannot stand in for a team member or
an operator-console session.

The shared credential contract defines these recognized grant names:

<!-- credential-grants:begin -->
| Grant |
| --- |
| `runs.read` |
| `runs.write` |
| `runs.control` |
| `runs.state` |
| `nodes.claim` |
| `triggers.claim` |
| `claims.launch` |
| `logs.read` |
| `secrets.read` |
| `approvals.write` |
| `team.admin` |
| `secrets.release` |
| `source.read` |
| `data.read` |
| `data.write` |
| `oidc.mint` |
| `node.report` |
| `node.result` |
| `log.tail` |
| `refs.read` |
| `children.start` |
| `trigger.host` |
| `claim.exchange` |
| `expansion.submit` |
| `operator.limits` |
| `operator.object_store` |
| `operator.tokens` |
| `operator.console` |
<!-- credential-grants:end -->

A runner enrollment requests `nodes.claim`, `triggers.claim` and `runs.state`.
The issuer validates provisioning authority. Granted repository access, reserved
pools and metering are server facts. A runner's free labels and a proposed
repository name grant no authority. `triggers.claim` remains a permission name.
Its public work queue is `/api/v1/runners/claim`.

Operational deployment credentials carry `operator.limits`,
`operator.object_store` or `operator.tokens` for their selected operations.
Trust changes, grants, freezes and account deletion require an
operator-console session. Those powers do not follow from a deployment token.

## Claim ownership

The controller derives the run, job, team, holder, generation, attempt,
repository, ref, reserved pools and billing facts from the awarded row. Caller
headers, labels and request fields cannot replace those facts. A mutation
rechecks the fence in the same transaction as its write. The store clock judges
whether the lease is live.

A live credential with insufficient authority receives `403`. A dead credential
receives `401`. Clients branch on `error.code` and use `details.reason` for the
specific refusal. Every boundary uses the same envelope:

```json
{"error":{"code":"forbidden","message":"operation requires team.admin; ask a team owner for a credential with team.admin","details":{"reason":"missing_grant"}}}
```

Grant refusals name the required grants. Secret writes require `team.admin`,
which the owner role holds. OIDC minting requires `oidc.mint` on an awarded
or delegated job credential. A team token cannot substitute for that job
credential. These messages describe the operation's policy and include no
resource identifiers. Foreign-team resources retain the same response as
absent resources.

A root that can claim work does not gain arbitrary execution writes. Awarded
credentials carry the operation grants and exact binding needed by their unit.
Run-host ownership and job ownership remain separate. A member's management
rights do not bypass a competing execution holder.

### Repository authority

A missing repository scope (`null`) is unrestricted. An explicit empty scope
(`[]`) grants no repository access. Delegation cannot widen that distinction.
Claim admission checks every ancestor against the same repository evaluator.
Canonical repository keys and enrollment path patterns remain separate types.
Patterns are checked against the work's persisted repository location.

### Claim tokens

The issuer has three operations:

- **Delegate** narrows its parent's grants, binding, repositories, data scope and
  lifetime. It cannot widen authority.
- **Award** creates the fixed credential for work selected under the claim
  transaction. Only launcher authority may select work across teams.
- **Exchange** consumes a bootstrap once and recovers its original unit's
  credential. A concurrent or repeated exchange cannot issue a second one.

Operational units are trigger, plan, eval and job. A here award retains trigger
ownership and follows the requesting capability or session. An ordinary claim
follows its claimant root. A delegated job-process credential follows its
parent and dies when its process exits. Revocation checks capability and session
ancestry, including the nearest here award.

Same-uid pipeline code may still reach a runner's credential. A private pipe or
filtered environment does not isolate hostile processes sharing an OS account.
Cloud jobs run rootful Docker inside a user-namespaced pod on nodes bound to one team and run, never privileged. Those are
accepted deployment boundaries, separate from the claim's API authority.

A pod bootstrap has only source-read and exchange authority. A bootstrap-specific
policy class admits it only to source acquisition and exchange. It cannot read
run metadata, events or approvals. Source initialization can use it before exchange. Exchange revokes it and retains its unit, binding and
billing provenance. The job receives no runner root credential.

Sensitive reads require a live claim. A terminal job has only its bounded window
to finish its own already-started log upload. Expansion submission uses its
live expansion authority. Neither exception reopens general source, secret or
data access.

## Secret ownership

Management access and execution release are separate decisions. An authorized
management read can return a value. Lists contain metadata only. Execution
release requires the bound claim and the accepted plan's secret declarations.
Secret values are encrypted in persistence and masked before rendering.

A secret restricted with `--only-on` names a granted reserved pool. The
controller checks that restriction at plan acceptance and again at release.
Here execution cannot use a pool-restricted secret. A self-asserted label that
spells the pool name grants nothing.

Anyone who can change and run a pipeline can make that pipeline use the secrets
it is authorized to read. Assign that access as carefully as access to the
secrets themselves. Registered secret values are refused in job, step, group
and approval IDs because those identifiers cannot be masked after storage.

## Metered runners

Metering follows server-recorded provenance. Runner labels, request fields and
audience prefixes cannot select billing. Here work inherits the requester and
keeps trigger-to-host billing. A normal award copies the registered claimant's
billing facts. Operational trigger work maps to host billing. Job work maps to
job billing. Plan and eval are separate billable units.

Reservation, charging, claim renewal and settlement happen in store transactions.
An unstarted claim refunds its reservation. A job that starts consumes the
minimum. A host has a separate minimum. Plan and eval have zero minimum and
charge their actual compute. Customer-owned runner wait intervals are excluded.

Billing reconciliation must distinguish cancellation, lease loss and execution
start. Repeating a finish or payment event cannot create another charge.
The CLI's transport tests do not establish these ledger properties.

## Credits

Credit availability and billing policy are team-scoped server facts. Claiming
work cannot bypass an exhausted balance by changing labels or supplying a
billing flag. An insufficient-credit refusal keeps its canonical structured
error. Pricing and compute measurements come from the controller's observations.

## Buying credits

`POST /api/v1/team/billing/checkout` and `POST /api/v1/team/billing/card`
require one nonempty
`Idempotency-Key` header. A committing transaction derives the team, account,
reservation and current billing limits. The checkout service receives that
reservation as its request key. A completed replay returns the recorded URL
without opening another page. Provider failure leaves the reservation available
for reconciliation. API failures use the canonical error envelope. Billing
refusal details include a `reason`. Cloud admissions follow [billing standing](billing.md).


The controller verifies Stripe signatures at `POST /webhooks/stripe`. Checkout
payments, failures and expiry carry the committed provider session identity.
Card setup and card payment outcomes, individual refund updates, early fraud
warnings and opened or closed disputes carry their provider identities and
verified status. The verifier reads a paid charge's 3-D Secure result from Stripe
and checks its identity, payment and test/live mode. It keeps no card details. Metadata grants no team,
credit, attempt or billing authority.

Settlement derives the team and ledger effect from committed billing records
and deduplicates provider events in its transaction. Checkout settlement is
implemented. Settlement of card, refund, warning and dispute events still needs
the store integration. A provider retry cannot safely be treated as a new
credit grant.

Test and live Stripe keys select configuration for the same implementation.
Checkout, payment history, card operations, payment holds and reversals retain
their account authorization. A signed payment event authorizes only its declared
payment operation. It grants no general team or operator power.

## GitHub Actions enrollment and runner metering

GitHub Actions enrollment uses `GET` and `POST /api/v1/runners/github` and
`DELETE /api/v1/runners/github/{repository}`. The dashboard uses these paths.
Each repository, workflow run and run attempt can exchange its GitHub identity
once. Run the Sparkwing runner in one job per attempt. Actions credentials cannot
be rotated. A new workflow attempt receives its own credential. The issuer
bounds its expiry to one hour from the store transaction clock and at most
one hour after the verified GitHub token expires.
Runner metering uses the console-only
`POST /api/v1/operator/runners/{runner}/metered`, selecting an enrolled root's
capability prefix.

Automated payment outcomes enter through the signed provider callback.
Service credentials cannot submit provider outcomes or grant team credit.

## Deployment bootstrap

Mount `bootstrap-admin-token` in the controller's `--credentials-dir`. Its value
is `swk_` followed by the unpadded base64url encoding of 32 random bytes. Startup
passes it to the once-only bootstrap transaction before opening the listener.
The store keeps its digest and grants `operator.limits`, `operator.object_store`
and `operator.tokens`. It grants no team membership, `runs.read`, `runs.write`,
runner enrollment or operator-console authority.

A live seed can be reused while setup is unfinished. Issuing the first real
deployment administrator closes the gate, revokes the seed and its descendants,
in one transaction. Remove the mounted seed
after that handoff. A seed left mounted after its handoff only logs a warning
at startup, because its digest matches the one that bootstrapped the store; a
different seed still stops startup. An empty input provisions nothing. Private
routes require live credentials even on an empty store. `--require-auth`, which
the chart always passes, also refuses startup without a live deployment
administrator capability.

Create a team and its first team-admin credential with
`sparkwing operator teams create --team support-test --display-name Support --profile deployment -o json`.
The route is `POST /api/v1/operator/teams` and requires `operator.tokens`, or an
operator's direct browser session with its CSRF proof. It creates the team,
owner account, membership and first credential in one audited transaction.
The credential is returned once. Existing team administrators issue member
credentials through the team namespace and enroll machines with
`sparkwing runners add --session-stdin --profile work --runner build-box --repos 'github.com/acme/*'`.
For headless enrollment, an existing team administrator uses the in-pod
`sparkwing-controller admin session` command. See
[workstation capacity](self-hosting.md#add-workstation-capacity) for the full pipe.

`profiles add` probes `/api/v1/health` and authenticates through
`/api/v1/auth/whoami`, which every live credential may call. Passing this probe
does not grant access to team runs. Set `SPARKWING_CONFIG` as well as
`SPARKWING_HOME` when saving profiles in an isolated CLI home. See
[Self-hosting](self-hosting.md#first-shared-deployment) for commands.

Working-tree submission uses `POST /api/v1/data/source` and
`POST /api/v1/data/source/{upload}/commit` under the submitter's `runs.write`
authority. Each step rechecks current team and repository authority. Signed
transfers carry only object-specific headers. Intake consumes one committed
upload matching its submitter, repository, both source revisions and digest.
It creates a source-only read edge for the awarded run; a member credential
does not gain arbitrary data downloads. Uploaded source has local provenance
and cannot assert GitHub App trust.


A `--deployment=team` controller refuses deployment-admin bearers. `local` and
`cloud` controllers accept them. Console sessions retain their separate account
gate.

## Operator console

Operator-console sessions own trust changes, credit grants, freezes and
account deletion. Refunds are made in the Stripe dashboard. They also read the standing queue, where team owners'
requests for High standing wait. A grant naming the reviewed request approves
it, and a decline names the request and records the reason its owners see. Deployment tokens manage operational limits, object-store
settings, deployment service credentials and new-team onboarding through their separate routes.
They cannot exchange themselves for console powers.

### Break-glass

When console access is unavailable, the deployment operator can use
`sparkwing-controller admin` inside the controller pod. This is an in-pod
administrative path with its own authority and audit. A remote deployment
credential does not open it. It freezes and releases teams and grants credits:

```bash
kubectl exec -n NAMESPACE deploy/RELEASE-sparkwing-full-controller -- \
  sparkwing-controller admin grant --operator ACCOUNT_ID --team qa-team --cents 500 --reason "QA fixture" --key qa-1
```

It acts on the store the serving controller holds. Once startup succeeds, the
controller records its `--deployment`, `--store`, `--credentials-dir`, where its
database connection landed (server, port, database and schema, or the SQLite
file) and when the store was created in
`$SPARKWING_HOME/deployment.json`. `admin` opens the existing store that record
names and checks that its own connection landed on the same target and store
before it acts. It never creates a store, and it refuses when there is no
record or when the store it reaches differs, for example after `pg-url` was
pointed at a restored copy.

Every grant requires `--key`. Reusing it reports the first grant instead of
granting again. To correct a mistaken free grant, use `--reverses` with its full
reference, for example `--key qa-correction --reverses admin:qa-1`. Keep `--cents`
positive. The command records a negative reversal bounded by the grant's remaining
amount. Paid credits use the payment refund path. Every command requires `--operator ACCOUNT_ID` with
your existing account ID and records that identity in the business event and
operator log. Pod access remains the authority for the command. Audit rows
exclude free-form reasons and store grant keys as digests.

## Retained storage

The controller owns `/data` authorization, records and signed transfers. Reads
check the caller's permitted records and digests. An uploaded digest is not a
team-wide permission to attach it to an unrelated manifest. Every reference
checks integrity, existence and authority.

Signed downloads expire after 60 seconds and verify the digest at EOF.
Revocation does not recall a URL already issued. Retention uses record
reachability and the declared collection window. Missing or corrupt bytes fail
as an error or cache miss. A separate cache service or cache credential is not
part of this model.

## Runner classes

Runner identity, reserved pools, metering and capacity remain separate fields.
The controller returns granted class and location alongside free labels,
repository fences, active jobs and measured headroom. Missing headroom means
there is no observation. It is not a zero-capacity sample.

Runner and queue views read one tenant-scoped store snapshot. Display names and
holder strings grant no identity. Distinct credentials with the same name remain
separate observations. Accepted renewals refresh liveness without offering a free
slot or refreshing headroom. The controller resolves the inventory reader from
the request's tenant handle. A missing adapter returns `policy_missing`.

A here runner is reserved for its run. Pool access comes from granted
reservations and verified repository authority. The first eligible matching
claim wins under the same matcher used for queued and named work.

## Compute guards

Operator values are ceilings. A pipeline value above a ceiling is refused with
both values and the setting's source. It is never silently clamped.
Execution, queue wait and approval wait are separate bounds. Local runs gain
no execution deadline. A queued job with a bounded owner also ends at the
owner's deadline. Approval zero remains indefinite wherever the owner can wait.

Deployment-wide counters live in the store. One controller holds a store at a
time, which still does not justify process-local financial or authorization
state.

## Teams and sign-in

A signed-in account's active team and membership determine its authority.
Requests cannot select another team merely by naming it. A demotion or removal
changes the membership generation checked by subsequent authorization.
Team-admin authority remains confined to that team.

### Team tokens

Member credentials act for an account in its team. Runner credentials act for a
registered machine. Issuance checks the account or registration and cannot
exceed its authority. Principal text is a display label. It cannot establish
membership, machine identity, reserved pools or metering.

A browser session opened with an account capability retains that capability as
its authority parent. Credential issuance through that session delegates from
its existing ancestry and inherits the shortest remaining lifetime. Independent
member roots require a direct account sign-in session.
Changing a token into a browser cookie does not establish one.
Account credentials carrying a data scope cannot sign in because browser
sessions cannot preserve that scope.

Machine roots require a direct account sign-in session or deployment authority.
Their grants are limited to `nodes.claim` and `triggers.claim`. The store checks
these rules in the transaction that issues the root.

### Linked sign-ins

Google and GitHub identities can be linked from an authenticated account.
A matching email address alone must not merge two accounts. Linking and
unlinking identities require the account's authorized session. A provider's
verified identity and the application's team membership remain separate facts.

Linking starts at `POST /auth/{provider}/link` with one `csrf_token` form field,
the matching CSRF and session cookies, and same-origin proof. It requires the
account's direct provider session from the last ten minutes. An account-token
session cannot add sign-in methods. The flow binds its account, session,
provider and PKCE verifier, expires after ten minutes, and can complete once.
The existing limit is ten link attempts per account per minute.

`GET /auth/{provider}/link/complete` uses that same session and flow cookie.
It consumes the flow before provider access and attaches only the verified
provider identity. Neither endpoint renews the session or accepts an
Authorization header. A refused link returns to `/account/sign-ins?refused=<reason>`,
and the page says what happened and what to do.
Controller construction binds the store operator as the identity-link writer
for committing session and nonce checks. A missing writer returns `policy_missing`.

A nonlocal dashboard requires a live session for its private pages. Its login page remains available to
signed-out browsers. `Dashboard.Local` selects local pages without browser login.

### Sign-up gate

The deployment's sign-up settings decide whether a new account proceeds or
waits for admission. The operator controls those settings. A team role or a
supplied email does not grant an exception. Authentication and tenant isolation
still apply after admission.

## Unauthenticated endpoints

Public discovery, sign-in, provider callbacks, webhooks and signed data URLs use
their declared proof requirements. A route being reachable without a bearer is
not an authorization bypass. A signed URL, verified webhook or provider proof
is valid only for its own operation. Every registered route declares its proof.

## Dashboard authorization

Browser sessions use account and team authority. State-changing browser requests
also require the session's CSRF protection. A session cookie is not a deployment
service credential. Operator-console powers require the console's session
checks. Membership and session ancestry remain live authorization inputs.

Licensed team and Cloud controllers use `__Host-sw_session` and
`__Host-sw_csrf`, including over HTTP loopback port-forwards. Session enrollment
clients send `__Host-sw_csrf` with the proof returned by `/api/v1/auth/session`
for enrollment and logout. The URL scheme does not select the cookie name.
The [Secure cookie localhost exception](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure)
allows loopback clients to keep the prefix. Licensed controllers refuse
`--insecure-cookies`.

### Google and GitHub sign-in

Provider sign-in verifies the callback state, proof-key flow and provider token
or identity response. It checks the configured redirect origin and the provider's
identity and verification claims. A refused sign-in returns to
`/login?refused=<reason>` (keeping `next`), and the sign-in page says what
happened and what to do. The reason is one of a fixed set that names no
account or provider detail: `expired`, `cancelled`, `email_unverified`,
`account_exists`, `no_team`, `reauth_required`, `direct_sign_in_required`,
`identity_linked_elsewhere`, `identity_already_linked`,
`provider_already_linked`, `rate_limited`, `unavailable` or `failed`. A refused
invitation acceptance carries `invitation_not_found` or `invitation_closed`
in `error.details.reason`. The store finds an invitation only through
an address the account owns, so a missing invitation and one sent elsewhere get
the same `invitation_not_found` answer; expiry and use are reported only for the
caller's own invitation. The refusal asks the user to sign in with the account
whose verified email matches the invitation, or ask a team owner for a new
invitation. Linking a provider does not grant repository
trust. GitHub App intake separately verifies its webhook and clones its exact
SHA. A subsequent branch-head lookup cannot replace that proof.

## Signing in

Sign in on the controller's configured dashboard origin, select the team you
belong to, and create the credential appropriate for the CLI or machine.
Controller enrollment checks account and machine authority. Possessing a
repository URL or typing a team name supplies neither.

<!-- design:end -->

## CLI

See [Credential management](credentials.md) for the implemented client surface.

<!-- design:begin -->

## Credential lookup

Capability lookup uses SHA-256 of the complete random token. Password hashing
remains a separate account concern. Every authenticated request reads current
authority, ancestry, membership and expiry in one store snapshot. The controller
has no bearer verification cache.

## How long revocation takes to bite

Revocation advances the affected authority generation in the same transaction.
Every descendant depends on live ancestry. Each request reads those facts again.

Revocation cannot recall a signed URL or an external GitHub or OIDC credential
already issued. Those retain their separate expirations. Stopping a runner is
not proof that an external credential was recalled.

A normal database restore keeps sessions, API tokens, runner and machine
credentials, and API/local schedule consent valid. Recover revocations, member
removals and demotions since the backup from the controller's audit log stream,
kept outside the database. Its revocation lines identify token prefixes,
accounts, machines or GitHub repository IDs and the team. Session logout and
replacement identify the ended session by `session_digest`, `revoked_account_id`
and `revoked_team`, including when the browser signs in as another account.
Controller logs fingerprint credential shapes in messages, attributes and errors.
Public token prefixes remain readable. Follow the operator runbook kept outside this repository.

For a stolen database, leaked keys or another security incident,
`sparkwing-controller restore prepare --sign-out-everyone` rotates the external
credential epoch. All earlier credentials and API/local schedule consent then
need replacement. See [Backup and restore](backup-restore.md#restore).

## When a runner's token dies

A dead root prevents new claims and invalidates descendant authority. Work that
cannot renew its claim stops. Replace the machine's private credential and
restart its supervised runner.

The final machine configuration uses `runner`, `credentials_dir`, `repos` and
labels. Native enrollment and service refresh must use that schema before
they can establish recovery behavior. Client fixtures do not establish config
hot reload, hourly parking or automatic resumption.

<!-- design:end -->

## Sealed execution trust

Intake seals the proven repository and verified default-branch fact in the
request. Claim issuance seals the host kind and cache scope in award authority.
Secret release, OIDC, source credentials, cache writes, latest-output reads and
repository-scoped runners read those facts. A submitted repository name never
establishes proof. Personal machines and here hosts cannot claim proven runs.
Job credentials additionally require the GitHub compare result sealed at intake
for both the source and execution commits when they differ. Pull refs compare
against the same-repository PR head branch. Merged PR runs compare against their
base branch. An asserted run can still have repository proof: a branch-pinned
`source:` selected through the App may receive a repository credential when both
commits pass the intake compare check. The asserted trust class alone does not
refuse that credential. Exact-revision children inherit their parent's seal.
Changed-revision and cross-repository children inherit no repository proof.
A changed-revision child in the same repository may receive a fetch-only GitHub
App token after intake compares both its source and execution commits against
that repository's branch or tag. The compare must return `identical` or `ahead`.
Unreachable commits, unknown compare results and cross-repository children receive
no token. Push the commit to a branch or tag in the parent's repository and start
a new run, or upload the source.

Fetch-only permission is sealed separately from repository proof. The child stays
asserted and receives no stored Git credentials, extra repositories, team or
pipeline secrets, or OIDC tokens. The App token requests only `contents: read`
for that repository's numeric ID. Issuance and its committed audit require the
current source-acquisition attempt's live authority, lease and generation.
Fetch-only tokens keep GitHub's provider lifetime of at most one hour, including
after the attempt ends. Sparkwing does not recall issued tokens. Public
repositories can still be fetched anonymously. Shared-secret eligibility does
not grant source credentials.
Verified cache fallbacks use the sealed repository proof. Unproven
runs read and write their own data scope. Unknown claimant shapes use the
personal host boundary and cannot claim repository-proven work.
GitHub compare responses include file patches even when commit pagination is
limited. Large comparisons retain verification when their status proves
reachability. If the intake compare cannot reach GitHub, times out, receives a
5xx or supplies unreadable JSON or an unknown status, webhook, native trigger
and schedule intake admit the run as asserted without repository proof. It
receives no job credentials, proven-repository secrets or
proven OIDC claims. Run status explains why verification failed. Definite
negative compare answers, including diverged, behind and 404, retain their
existing behavior.

Latest pipeline references select verified default-branch work unless the
pipeline declares `sw.FromAnyRun()`. A reference declared with
`sw.FromProvenRunAtCommit()` reads only a run at the reader's own sealed commit
whose repository was proven (an App delivery, an Actions push, a schedule, or
code the controller fetched from GitHub) as the reader's proven repository, and
whose sealed options selected every job and step: no `--sw-only`,
`--sw-start-at` or `--sw-stop-at`, no dry run, no debug and no
`--sw-pause-before`, `--sw-pause-after` or `--sw-pause-on-failure`, because a
paused job can be attached to and edited. The run must also have
sealed no environment variables, because a submitted `GOFLAGS` or similar can
change what its jobs judged, and every job that did not skip must have run on a
trusted host (see [security](security.md)). The newest such run in which the referenced job
succeeded answers, so a newer run that skipped the job does not hide an older
one that ran it. An asserted run, such as a
plain local run, proves no repository, so it neither reads nor satisfies such
a reference.
