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 for the CLI surface.

Cloud operations and test environmentsSection anchor link

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

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). 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 authorizationSection anchor link

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 authoritySection anchor link

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 discoverySection anchor link

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 authoritySection anchor link

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 rotationSection anchor link

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 lifecycleSection anchor link

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.

Token formatSection anchor link

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.

ScopesSection anchor link

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:

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

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 ownershipSection anchor link

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:

{"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 authoritySection anchor link

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 tokensSection anchor link

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 ownershipSection anchor link

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 runnersSection anchor link

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.

CreditsSection anchor link

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 creditsSection anchor link

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.

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 meteringSection anchor link

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 bootstrapSection anchor link

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 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 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 consoleSection anchor link

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-glassSection anchor link

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:

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 storageSection anchor link

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 classesSection anchor link

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 guardsSection anchor link

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-inSection anchor link

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 tokensSection anchor link

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-insSection anchor link

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 gateSection anchor link

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 endpointsSection anchor link

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 authorizationSection anchor link

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 allows loopback clients to keep the prefix. Licensed controllers refuse --insecure-cookies.

Google and GitHub sign-inSection anchor link

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 inSection anchor link

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.

CLISection anchor link

See Credential management for the implemented client surface.

Credential lookupSection anchor link

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 biteSection anchor link

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.

When a runner's token diesSection anchor link

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.

Sealed execution trustSection anchor link

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). 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.