store

package
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package store provides typed CRUD over the Wardyn schema using pgx/v5. All writes are serialised through pgxpool; callers supply contexts with deadlines. Most operations are methods on PG (see iface.go); InsertAuditEvent stays a free function taking the pool explicitly since it predates a Store value in the audit.Recorder wiring.

Naming conventions:

  • Create* inserts and returns the full hydrated row.
  • Get* fetches by primary key; returns ErrNotFound when absent.
  • List* returns a slice (empty, never nil) without a hard limit unless stated.
  • Update*/Decide* are point mutations with explicit optimistic guards.

Per-user API tokens (migration 0045). Kept out of store.go on purpose (it sits at a lint size boundary), mirroring store_sshkeys.go's split.

The raw token never reaches SQL: every method here takes either an id or the raw string and hashes it with hashToken (store_ephemeral.go) before touching the table, so api_tokens.token_sha256 is the only form that exists at rest.

Capability grants and the per-kind enforcement switch (migration 0042). Kept out of store.go on purpose, mirroring store_sshkeys.go's split.

Short-lived control-plane handoff row (migration 0026): single-use WS attach tickets. Was an in-process map, so a second control plane never saw it and a restart dropped it. Consume-once, and here that is a single DELETE ... RETURNING — the atomic form of the map's delete-on-read, exact under concurrency AND across processes. Kept out of store.go on purpose (it sits at a lint size boundary).

Sandbox ref -> substrate routing rows (migration 0021). This is the Postgres implementation of the orchestrator's RefStore seam: the orchestrator write-throughs each created sandbox's ref and owning-substrate NAME here so a control-plane restart can rehydrate lifecycle routing (Exec/Wait/Attach/ Status/Stop/Kill — i.e. the kill switch) in multi-substrate deployments. Kept out of store.go on purpose (it sits at a lint size boundary).

SSH gateway key registry (migration 0033). Kept out of store.go on purpose (it sits at a lint size boundary), mirroring store_sandbox_ref.go's split.

Per-user run-detail cockpit widget-layout persistence (migration 0037). Kept out of store.go/iface.go on purpose — see RunLayoutStore's doc below.

The run-watcher lease (migration 0027): the two writes that make "who is responsible for finishing this run" a fact in Postgres instead of a goroutine in one process. Kept out of store.go on purpose (it sits at a lint size boundary); the run columns they read back are the same ones store.go's CreateRun/GetRun and pagination.go's ListRunsPage select.

The workspaces table (tier 3 of the three-tier split): its JSONB parameter helpers, the canonical wsCols column list, scanWorkspace, and the CRUD and scoped-writer methods PG exposes over a workspace row. Split out of store.go along the table seam — store.go had grown past the file-size lint and this was its largest single-table cluster, cohesive enough to move whole: nothing here is reachable except through a workspaces row.

It is the write half of the pair store_sources.go completes: that file owns tiers 1 and 2 (the sources library and base-image catalog) plus the hydrate pass, and the scoped writers below call its s.hydrated to materialize the derived read-only view before returning. One workspace-row writer stays over there rather than here — MergeWorkspaceRequirements, the verify loop's overlay merge — because it belongs to the requirements seam it shares with the source tiers, not to this file. The two files stay separate because hydration is a read concern shared with pagination.go's ListWorkspacesPage, while everything here is a single-row statement against `workspaces`.

Index

Constants

This section is empty.

Variables

View Source
var ErrAlreadyDecided = types.ErrApprovalAlreadyDecided

ErrAlreadyDecided is returned when DecideApproval is called on an approval that has already left the PENDING state. Fail closed: never allow a second decision to silently overwrite the first.

View Source
var ErrConflict = errors.New("store: conflict")

ErrConflict reports a fenced write losing its race: a source scan slot already claimed, or the requirements merge hitting the key cap.

View Source
var ErrDuplicatePending = types.ErrDuplicatePendingApproval

ErrDuplicatePending is returned by CreateApproval when a partial unique index (approvals_pending_credential_uniq / approvals_pending_noncred_uniq) rejects a second open PENDING approval for the same dedup key — i.e. a concurrent raise lost the race. Callers treat it as a dedup signal (re-read the existing PENDING row and return it), NOT a hard failure. approval.RequestApproval errors.Is-es this exact value; both names alias the one sentinel in internal/types.

View Source
var ErrNotFound = errors.New("store: not found")

ErrNotFound is returned when a Get* call finds no row.

Functions

func InsertAuditEvent

func InsertAuditEvent(ctx context.Context, pool *pgxpool.Pool, ev *types.AuditEvent) error

InsertAuditEvent appends a single audit event. Implements audit.Recorder. The Postgres trigger blocks UPDATE/DELETE; this function only ever INSERTs.

ev is taken by POINTER so the hash chain (migration 0047) can be handed back: on success ev.PrevHash/ev.RowHash carry the values Postgres computed, and ev.RowHash IS the chain head at that instant. cmd/wardynd's fanoutRecorder emits that same value to the audit sinks, which is how an external SIEM ends up holding a head hash Wardyn cannot later disown.

It runs in a transaction for ONE reason: pg_advisory_xact_lock must be held across the INSERT so the identity default (seq) and 0047's head read happen under the same lock, keeping seq order and chain order identical. See db.AuditChainLockKey.

Types

type ApprovalsByRunCreatorPager added in v0.5.0

type ApprovalsByRunCreatorPager interface {
	ListApprovalsPageByRunCreator(ctx context.Context, createdBy string, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)
}

ApprovalsByRunCreatorPager is the ownership-scoped analogue of Pager.ListApprovalsPage: a member's GET /approvals (no ?run_id=) is scoped to approvals raised on runs THEY created. Approvals carry no created_by of their own (they belong to a run, not a human directly), so this JOINs agent_runs. Same fail-closed contract as RunsByCreatorPager — an absent implementation must never fall back to the unscoped list.

api.Config.Approvals is wardynd's approvalService wrapper (cmd/wardynd/adapters.go), not a bare store.PG, so it needs its own delegation method for this to be reachable in production — it has one, and asserts the interface, so a member's unscoped GET /approvals is served from the store rather than the api-layer fail-closed fallback.

type AttachTicket added in v0.5.0

type AttachTicket struct {
	RunID     uuid.UUID
	ActorType types.ActorType
	Principal string
	Role      string
}

AttachTicket is what one redeemed single-use WS attach ticket carries: the run it is bound to, the principal that minted it (attribution — the session.attach audit names the human, never the ticket), and that principal's role (admin/member — internal/auth/oidc's RoleAdmin/RoleMember) at mint time. The ?ticket= WS lane bypasses humanOrAdminAuth entirely, so this stamped role is the only signal available to re-check owner-or-admin at consume time (see internal/api's ticketOrHumanAuth / handleAttachWS).

type AuditChainStatus added in v0.6.0

type AuditChainStatus struct {
	// OK is true when every chained row re-hashed to its stored row_hash and
	// linked to its predecessor.
	OK bool `json:"ok"`
	// Checked is how many chained rows the sweep walked.
	Checked int64 `json:"checked"`
	// Legacy is how many rows carry NO hash at all — rows that predate
	// migration 0047. They are outside the chain by design and are never a
	// failure; a non-zero value on an upgraded deployment is expected.
	Legacy int64 `json:"legacy"`
	// FirstSeq/HeadSeq bound the chained range (both 0 when Checked is 0).
	FirstSeq int64 `json:"first_seq"`
	HeadSeq  int64 `json:"head_seq"`
	// HeadHash is the row_hash of the newest chained row: the value to compare
	// against what a SIEM recorded off the sink stream.
	HeadHash string `json:"head_hash,omitempty"`
	// BrokenSeq/Reason are set only when OK is false.
	BrokenSeq int64  `json:"broken_seq,omitempty"`
	Reason    string `json:"reason,omitempty"`
}

AuditChainStatus is one verification sweep's verdict (migration 0047).

OK is the only field an alert should key on. Everything else is context for the operator reading the result: HeadHash is what to compare against the last head an off-box SIEM recorded (the check the chain structurally cannot perform on its own — see AuditChainVerifier), and BrokenSeq/Reason name the FIRST row that failed rather than every row after it, because one edited row makes every later link mismatch too and listing them all buries the edit.

type AuditChainVerifier added in v0.6.0

type AuditChainVerifier interface {
	VerifyAuditChain(ctx context.Context) (AuditChainStatus, error)
}

AuditChainVerifier is the OPTIONAL store capability behind GET /api/v1/audit/chain/verify. Optional for the same reason Pager is: a test fake or a non-Postgres store has no chain, and the handler answers 501 rather than reporting that a chain it never wrote verified clean.

The sweep is OPERATOR-INVOKED and never runs at boot. It reads and re-hashes every chained row, which is O(whole audit log) — paying that on every wardynd start would tax the common case (no tamper) for a result nobody is watching at that moment. It is also NOT a substitute for comparing HeadHash against an off-box copy: an actor who can rewrite one row can usually rewrite the tail and re-chain it, and a re-chained tail verifies clean. See docs/OPERATIONS.md.

type AuditFilter added in v0.4.4

type AuditFilter struct {
	Since        time.Time       // events at or after this instant
	Until        time.Time       // events strictly before this instant
	Action       string          // exact action, e.g. "run.kill"
	ActionPrefix string          // action family, e.g. "credential." or "egress."
	Actor        string          // exact principal, e.g. "alice@corp.example" (human evidence)
	ActorType    types.ActorType // human / agent / system
	Outcome      string          // success / failure / warn
}

AuditFilter narrows the audit feed to answer the questions a bigger window never can — "what happened between 14:00 and 16:00", "every secret.write this quarter", "every failure" — where the answer is 40 events inside 200k. The zero value matches everything, so an unfiltered read keeps taking the plain QueryRecentAuditEventsPage / QueryAuditEventsPage path unchanged.

It renders TWO ways from one declaration — SQL (where) for the Pager path and Go (Matches) for the fetch-all fallback — because a filter applied on only one of them returns silently UNFILTERED events on the other, which in a governance product is a quiet wrong answer. Keep the two in step; they are deliberately adjacent.

Actor IS filterable (D6): for a HUMAN event it is the operator/member principal (e.g. "alice@corp.example"), so ?actor= answers "everything developer X did" — the per-principal evidence a vendor/compliance question needs and that no wider window yields. (An earlier note dismissed actor as filterable because for an AGENT event it is the per-run SPIFFE ID and would merely restate ?run_id= — but that reasoning only ever considered agent events; a human's approvals, kills, and policy writes all carry their principal here, and that is exactly what the finding asks for.) The agent-side "everything under X's runs" view still needs the agent_runs.created_by join, which does not fit this table-local filter.

func (AuditFilter) IsZero added in v0.4.4

func (f AuditFilter) IsZero() bool

IsZero reports whether the filter narrows nothing.

func (AuditFilter) Keep added in v0.4.4

func (f AuditFilter) Keep(events []types.AuditEvent) []types.AuditEvent

Keep returns the events matching f, in order. A zero filter returns events unchanged (no copy).

func (AuditFilter) Matches added in v0.4.4

func (f AuditFilter) Matches(ev types.AuditEvent) bool

Matches reports whether ev satisfies every set predicate.

type PG

type PG struct {
	Pool *pgxpool.Pool
}

PG is the Postgres-backed Store: its methods (defined in store.go) hold the query bodies directly, so there is exactly one implementation of each query.

func NewPG

func NewPG(pool *pgxpool.Pool) PG

NewPG returns a PG Store over pool.

func (PG) AddSSHKey added in v0.5.0

func (s PG) AddSSHKey(ctx context.Context, k types.SSHPublicKey) (types.SSHPublicKey, error)

AddSSHKey inserts a new registered key. Returns ErrConflict when the fingerprint (the PK) already exists — a unique_violation (23505) on this table means someone already registered that exact key material.

func (PG) AddWorkspaceEgressDecision added in v0.5.0

func (s PG) AddWorkspaceEgressDecision(ctx context.Context, id uuid.UUID, host string, allow bool, maxApprovedEgress int) (types.Workspace, error)

AddWorkspaceEgressDecision records one `always`-scoped egress decision for host: on allow it is added to approved_egress (capped at maxApprovedEgress, deduped) and removed from denied_egress; on deny the mirror. host is used verbatim — the caller normalizes and validates it (hostrules.ValidApprovedHost et al.; see internal/api's Phase 2 write-back), matching every other workspace writer in this file.

maxApprovedEgress is passed in rather than respelled as a SQL literal here: the single Go const (internal/api/workspaces.go) also enforces the bulk PUT's cap, so the rule lives in exactly one place.

Returns ErrConflict — not a bare "not found" — when id exists but the cap guard refused the write, distinguishing "no such workspace" from "cap reached" for the caller while keeping the same (types.Workspace, error) shape every sibling writer uses. Same disambiguation MergeWorkspaceRequirements uses for its own key-count cap.

func (PG) ClaimSourceActiveRun added in v0.5.0

func (s PG) ClaimSourceActiveRun(ctx context.Context, id, runID uuid.UUID) error

ClaimSourceActiveRun fences a source's in-flight scan run — the exact job ClaimWorkspaceActiveRun did for whole-workspace scans before the retarget. Returns ErrConflict when another run already holds the slot.

func (PG) ClaimStaleRunWatchers added in v0.5.0

func (s PG) ClaimStaleRunWatchers(ctx context.Context, owner string, staleAfter time.Duration) ([]types.AgentRun, error)

ClaimStaleRunWatchers atomically takes the watcher lease, for owner, on every non-terminal run that HAS a sandbox and whose lease has been silent longer than staleAfter — returning exactly the runs it claimed, for the caller to re-adopt.

The single conditional UPDATE ... RETURNING *is* the mutual exclusion. Two replicas sweeping at the same instant both re-evaluate the WHERE against the row version they blocked on, so the loser sees the winner's fresh heartbeat and returns no row — no advisory lock needed (and none wanted: db.TryAdvisoryLock borrows a pool connection for the entire hold, so one lock per in-flight run would exhaust the pool).

Two predicates carry the safety of the whole sweep:

  • the state list (nonTerminalRunStates) is the complement of types.RunState.IsTerminal, and matches agent_runs_watcher_sweep_idx's predicate verbatim so the partial index serves it. A finished run is never re-adopted.
  • a non-empty sandbox_ref restricts the sweep to runs that actually have something to watch. A run row exists for its whole pre-dispatch window (grant writes, then a multi-minute image build), and claiming one of those would hand the caller a run that merely LOOKS abandoned. Cleaning those up stays boot-only, where the dispatching process is known to be gone.

updated_at is deliberately NOT touched: it is the idle reaper's activity signal, and a lease write is not run activity.

func (PG) ClaimWorkspaceActiveRun

func (s PG) ClaimWorkspaceActiveRun(ctx context.Context, id, runID uuid.UUID, expected *uuid.UUID) (types.Workspace, bool, error)

ClaimWorkspaceActiveRun compare-and-sets active_run_id from expected (possibly nil) to runID — the atomic serial-import-step gate. Two concurrent step launches that both observed the same free slot cannot both win: the loser gets applied=false and must NOT launch. Returns ErrNotFound only when the workspace does not exist.

func (PG) ClearSourceActiveRun added in v0.5.0

func (s PG) ClearSourceActiveRun(ctx context.Context, id, runID uuid.UUID) error

ClearSourceActiveRun releases the fence (idempotent; only the named run's claim is cleared, so a stale clear can't stomp a newer claim).

func (PG) ClearWorkspaceActiveRun

func (s PG) ClearWorkspaceActiveRun(ctx context.Context, id, runID uuid.UUID) (bool, error)

ClearWorkspaceActiveRun clears active_run_id ONLY while it still points at runID (conditional, single statement) — a terminal run's cleanup can never clobber a step that was concurrently launched and now owns the pointer.

func (PG) ConsumeAttachTicket added in v0.5.0

func (s PG) ConsumeAttachTicket(ctx context.Context, token string, now time.Time) (AttachTicket, bool, error)

ConsumeAttachTicket redeems token exactly once, returning the ticket it stood for. The DELETE ... RETURNING is the single-use guarantee: two racing redemptions can only have one return a row.

The run-id binding is checked by the CALLER, not here, so a redemption against the wrong run still BURNS the ticket — the in-memory map deleted on any redemption attempt and that property is load-bearing (a leaked ticket probed against a guessed run must not survive the probe). Expiry is in the WHERE instead: an expired row is unredeemable anyway and the next mint sweeps it. A miss and an expired row are both (ok=false), indistinguishable to the caller.

func (PG) CreateAPIToken added in v0.6.0

func (s PG) CreateAPIToken(ctx context.Context, t types.APIToken, raw string) (types.APIToken, error)

CreateAPIToken inserts one token row. raw is the PLAINTEXT credential; only its hash is stored, and the caller is responsible for returning the plaintext to its creator exactly once (it is unrecoverable afterwards). t.Token is ignored — passing the secret twice would be the one way to accidentally persist it.

A unique_violation (23505) on token_sha256 is ErrConflict: that is a raw collision in a 256-bit random space, so in practice it means the caller reused a token value rather than minting a fresh one.

func (PG) CreateApproval

func (s PG) CreateApproval(ctx context.Context, a types.ApprovalRequest) (types.ApprovalRequest, error)

CreateApproval inserts a new approval request.

func (PG) CreateGrant

func (s PG) CreateGrant(ctx context.Context, g types.CredentialGrant) (types.CredentialGrant, error)

CreateGrant inserts a credential grant (eligibility record) and returns it.

func (PG) CreatePolicy

func (s PG) CreatePolicy(ctx context.Context, p types.RunPolicy) (types.RunPolicy, error)

CreatePolicy inserts a policy and returns the persisted row. Returns ErrConflict when the name's UNIQUE constraint (run_policies.name) rejects a duplicate — the caller maps that to 409, never the raw driver error (W20-S1-3).

func (PG) CreateRun

func (s PG) CreateRun(ctx context.Context, r types.AgentRun) (types.AgentRun, error)

CreateRun inserts a new run and returns the persisted row.

func (PG) CreateWorkspace

func (s PG) CreateWorkspace(ctx context.Context, ws types.Workspace) (types.Workspace, error)

CreateWorkspace inserts an onboarded workspace and returns the persisted row. Profile is internal/workspacescan's opaque WorkspaceProfile blob (nil until scanned).

func (PG) DecideApproval

func (s PG) DecideApproval(ctx context.Context, id uuid.UUID, decision types.ApprovalDecision) (types.ApprovalRequest, error)

DecideApproval transitions an approval from PENDING to decision.State. Returns ErrAlreadyDecided if the approval is not PENDING (fail-closed). Uses a single UPDATE with WHERE state='PENDING' to prevent TOCTOU races.

The SET clause below is a SEPARATE list from the RETURNING clause — update only the RETURNING and decision.Scope/ExpiresAt would never persist while the RETURNING happily echoes the un-updated row back, a green result over a silent no-op. Both lists must carry decision_scope/decision_expires_at.

func (PG) DeleteBaseImage added in v0.5.0

func (s PG) DeleteBaseImage(ctx context.Context, id uuid.UUID, detach bool) error

DeleteBaseImage removes a catalog row; detach=true first drops every workspace reference (those workspaces fall back to the derived recommended build — NULL is the marker, so "detach" is honest, not destructive). detach=false narrows the in-use check and the delete to ONE statement — mirrors DeleteSource, including its residual race (STORE-2): a concurrent workspace UPDATE committing base_image_id=id between this statement's own NOT EXISTS check and its commit is caught by the base_image_id FK (0031:61) instead, surfacing as a raw 23503 — mapped to ErrConflict below so that loses race still answers a clean 409, not a raw Postgres 500.

func (PG) DeleteCapabilityGrant added in v0.6.0

func (s PG) DeleteCapabilityGrant(ctx context.Context, id uuid.UUID) error

DeleteCapabilityGrant removes one grant by id. Returns ErrNotFound when no row matched — this is an admin-only surface, so there is no principal to scope the delete to and no existence oracle to worry about.

func (PG) DeletePolicy

func (s PG) DeletePolicy(ctx context.Context, id uuid.UUID) error

DeletePolicy removes a policy by id. Returns ErrNotFound when no row matched. Note: agent_runs.policy_id has NO foreign key, so a delete always succeeds even while runs still reference the policy — those runs keep a dangling policy_id. The run's authorization envelope survives regardless: dispatch records the fully-widened spec as a run.policy.effective event in the append-only audit log.

func (PG) DeleteRef added in v0.3.1

func (s PG) DeleteRef(ctx context.Context, ref string) error

DeleteRef removes the row for ref. Idempotent: deleting a missing ref is nil.

func (PG) DeleteSSHKey added in v0.5.0

func (s PG) DeleteSSHKey(ctx context.Context, fingerprint, principal string) error

DeleteSSHKey removes fingerprint, scoped to principal so a human can only ever delete their OWN key. Returns ErrNotFound both when the fingerprint doesn't exist and when it belongs to someone else — indistinguishable on purpose (no existence leak across principals).

func (PG) DeleteSource added in v0.5.0

func (s PG) DeleteSource(ctx context.Context, id uuid.UUID, detach bool) error

DeleteSource removes a library source. detach=true first strips every workspace attachment referencing it (the ?force=1 escape) — UNLESS doing so would leave a workspace with zero attachments (workspacesOrphanedBySource): 0029 makes a workspace a composition of one-or-more sources, and decodeWorkspaceRequest already guarantees every write keeps that true, so '[]' must stay unreachable (STORE-1). That check and the detach are two statements — narrows the window against a workspace attaching uniquely to this source between them, same as detach=false's own residual race below; neither closes it (STORE-2's finding on this exact file: READ COMMITTED against two independently-written tables can narrow a TOCTOU to one statement, never fully close it without an explicit lock on both sides).

func (PG) DeleteWorkspace

func (s PG) DeleteWorkspace(ctx context.Context, id uuid.UUID) error

DeleteWorkspace removes a workspace by id. Returns ErrNotFound when no row matched.

func (PG) GetAPITokenByRaw added in v0.6.0

func (s PG) GetAPITokenByRaw(ctx context.Context, raw string) (types.APIToken, error)

GetAPITokenByRaw is the auth-time lookup: given the bearer string a caller presented, resolve the LIVE token row it stands for. Deliberately UNSCOPED by principal — the caller has not authenticated yet; this call is what authenticates them.

`revoked_at IS NULL` is in the WHERE, not checked by the caller, and that is the security shape: a revoked token, an unknown token and a token whose hash does not match all fail IDENTICALLY with ErrNotFound, so the boundary is not an oracle for "this token used to exist".

func (PG) GetApproval

func (s PG) GetApproval(ctx context.Context, id uuid.UUID) (types.ApprovalRequest, error)

GetApproval returns the approval for id, or ErrNotFound.

func (PG) GetBaseImage added in v0.5.0

func (s PG) GetBaseImage(ctx context.Context, id uuid.UUID) (types.BaseImageEntry, error)

GetBaseImage returns the catalog row for id, or ErrNotFound.

func (PG) GetBaseImagesByIDs added in v0.5.0

func (s PG) GetBaseImagesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]types.BaseImageEntry, error)

GetBaseImagesByIDs returns the base images for ids in ONE query, keyed by id — hydrateAll's bulk read, mirroring GetSourcesByIDs. Missing ids are simply absent from the map (a dangling base_image_id contributes nothing; hydrateWorkspace leaves BaseImage nil for it).

func (PG) GetCapabilityEnforcement added in v0.6.0

func (s PG) GetCapabilityEnforcement(ctx context.Context) (map[string]bool, error)

GetCapabilityEnforcement returns the per-kind switch map. An ABSENT row means NOT ENFORCED, so the returned map is sparse by design and a missing key reads as false — that default is the whole zero-config back-compat story (see the migration). Never nil.

func (PG) GetPolicy

func (s PG) GetPolicy(ctx context.Context, id uuid.UUID) (types.RunPolicy, error)

GetPolicy returns the policy for id, or ErrNotFound.

func (PG) GetRef added in v0.3.1

func (s PG) GetRef(ctx context.Context, ref string) (string, bool, error)

GetRef returns the substrate name for ref. A missing row is (found=false, nil error) — pre-migration and unknown refs are expected, not errors.

func (PG) GetRun

func (s PG) GetRun(ctx context.Context, id uuid.UUID) (types.AgentRun, error)

GetRun returns the run for id, or ErrNotFound.

func (PG) GetRunLayout added in v0.5.0

func (s PG) GetRunLayout(ctx context.Context, principal, preset string) (types.RunLayout, error)

GetRunLayout reads the one (principal, preset) row scoped to principal. Unlike GetSSHKeyByFingerprint's deliberately-unscoped pre-auth lookup, a layout is never looked up by anything but its owner, so the WHERE carries both key columns from the start.

func (PG) GetSSHKeyByFingerprint added in v0.5.0

func (s PG) GetSSHKeyByFingerprint(ctx context.Context, fingerprint string) (types.SSHPublicKey, error)

GetSSHKeyByFingerprint is the gateway's pre-auth lookup: given the offered key's fingerprint, resolve which principal (if any) registered it — and with which role (0043), the gateway's admin-override signal. Deliberately UNSCOPED by principal — the caller has not authenticated yet; this call is what authenticates them. Returns ErrNotFound when unregistered.

func (PG) GetSiteConfig

func (s PG) GetSiteConfig(ctx context.Context) (types.SiteConfig, error)

GetSiteConfig returns the operator-wide site config, or a ZERO-VALUE SiteConfig (not an error) when no row has been written yet — first boot has no config, and "unconfigured" is a valid, common state rather than a failure the caller must special-case.

func (PG) GetSource added in v0.5.0

func (s PG) GetSource(ctx context.Context, id uuid.UUID) (types.Source, error)

GetSource returns the source for id, or ErrNotFound.

func (PG) GetSourcesByIDs added in v0.5.0

func (s PG) GetSourcesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]types.Source, error)

GetSourcesByIDs returns the sources for ids in ONE query, keyed by id — the hydrate pass's bulk read. Missing ids are simply absent from the map (a dangling attachment contributes nothing; the fold and the mount gate each handle that in their own register).

func (PG) GetWorkspace

func (s PG) GetWorkspace(ctx context.Context, id uuid.UUID) (types.Workspace, error)

GetWorkspace returns the workspace for id, or ErrNotFound.

func (PG) HeartbeatRunWatcher added in v0.5.0

func (s PG) HeartbeatRunWatcher(ctx context.Context, id uuid.UUID, owner string) error

HeartbeatRunWatcher refreshes the lease on id for owner — the "I am still watching this run" write every watcher goroutine repeats while it lives. Its silence is what lets another replica's ClaimStaleRunWatchers take over.

Unconditional on the current owner: a watcher cannot abort its blocking Runner.Wait anyway, so learning it lost the lease would give it nothing to do, and two watchers are already harmless (the terminal transition is a CAS, so only one can win). Like the claim, it does NOT bump updated_at — a 30s heartbeat on the idle reaper's activity column would make every run look forever-active and silently disable idle auto-stop. A missing row is not an error: the lease is advisory.

func (PG) LatestAuditEventByAction

func (s PG) LatestAuditEventByAction(ctx context.Context, action string) (types.AuditEvent, error)

LatestAuditEventByAction returns the most recent audit event whose action equals the given action, or ErrNotFound when none exists. Used by /healthz to find the latest kernel.sensor.heartbeat that drives the eBPF ground-truth health state (so the stream reports healthy only while beats are arriving).

func (PG) ListAPITokens added in v0.6.0

func (s PG) ListAPITokens(ctx context.Context) ([]types.APIToken, error)

ListAPITokens returns every token in the deployment, newest first — the admin inventory (GET /tokens). Revoked rows included, same reason as the self-service list.

func (PG) ListAPITokensByPrincipal added in v0.6.0

func (s PG) ListAPITokensByPrincipal(ctx context.Context, principal string) ([]types.APIToken, error)

ListAPITokensByPrincipal returns principal's own tokens, newest first — the self-service GET /me/tokens list. Revoked rows are INCLUDED: a human needs to see that the token they retired is in fact retired, and the row carries no usable credential either way.

func (PG) ListApprovals

func (s PG) ListApprovals(ctx context.Context, stateFilter types.ApprovalState) ([]types.ApprovalRequest, error)

ListApprovals returns approvals filtered by state. Pass empty string to list all.

func (PG) ListApprovalsPage added in v0.3.1

func (s PG) ListApprovalsPage(ctx context.Context, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)

ListApprovalsPage returns approvals filtered by state (empty = all) in reverse request order, bounded by p. The all-state feed rides approvals_requested_at_idx (0020); a single-state filter rides approvals_state_requested_at_idx (0023), which serves both the WHERE and the ORDER BY without a sort.

func (PG) ListApprovalsPageByRunCreator added in v0.5.0

func (s PG) ListApprovalsPageByRunCreator(ctx context.Context, createdBy string, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)

ListApprovalsPageByRunCreator is ListApprovalsPage narrowed to approvals on runs createdBy owns, via a JOIN on agent_runs (approvals has no created_by of its own). Same state filter and ordering as ListApprovalsPage.

func (PG) ListBaseImages added in v0.5.0

func (s PG) ListBaseImages(ctx context.Context) ([]types.BaseImageEntry, error)

ListBaseImages returns the whole catalog, newest first.

func (PG) ListCapabilityGrants added in v0.6.0

func (s PG) ListCapabilityGrants(ctx context.Context) ([]types.CapabilityGrant, error)

ListCapabilityGrants returns every grant, oldest first — the admin Permissions screen's whole table in one read.

func (PG) ListCapabilityGrantsFor added in v0.6.0

func (s PG) ListCapabilityGrantsFor(ctx context.Context, users, groups []string) ([]types.CapabilityGrant, error)

ListCapabilityGrantsFor returns every grant that could apply to one caller: the `all` rows, plus `user` rows naming any of users (the caller's lowercased sub AND email — a grant on either hits), plus `group` rows naming any of groups (the login-time claim snapshot).

Deliberately NOT filtered by capability: the resolver needs one kind and GET /me/capabilities needs all four, and a deployment's grant list is small enough that one round trip serving both beats two indexes and two queries. The per-kind and per-value matching (wildcards, host suffixes) then happens in Go, where the one host matcher already lives.

ponytail: no cache. This runs per request, on the (subject_type, subject) index; a process-local cache is the HA blocker OPERATIONS already names for other state, and a stale permission cache is a security bug, not a slow page. Add one only behind a shared invalidation channel.

func (PG) ListGrantsByRun

func (s PG) ListGrantsByRun(ctx context.Context, runID uuid.UUID) ([]types.CredentialGrant, error)

ListGrantsByRun returns all grants for a run.

func (PG) ListPolicies

func (s PG) ListPolicies(ctx context.Context) ([]types.RunPolicy, error)

ListPolicies returns all policies in reverse creation order. The slice is empty (never nil) when no policies exist.

func (PG) ListPoliciesPage added in v0.3.1

func (s PG) ListPoliciesPage(ctx context.Context, p Page) ([]types.RunPolicy, error)

ListPoliciesPage returns policies in reverse creation order, bounded by p. run_policies_created_at_idx (0023) covers the ORDER BY.

func (PG) ListRuns

func (s PG) ListRuns(ctx context.Context) ([]types.AgentRun, error)

ListRuns returns all runs in reverse creation order (unbounded).

func (PG) ListRunsPage added in v0.3.1

func (s PG) ListRunsPage(ctx context.Context, p Page) ([]types.AgentRun, error)

ListRunsPage returns runs in reverse creation order, bounded by p. The agent_runs_created_at_idx (0020) makes the ORDER BY + LIMIT an index scan.

func (PG) ListRunsPageByCreator added in v0.5.0

func (s PG) ListRunsPageByCreator(ctx context.Context, createdBy string, p Page) ([]types.AgentRun, error)

ListRunsPageByCreator is ListRunsPage narrowed to one creator, same order. ponytail: no dedicated (created_by, created_at) index yet — agent_runs is small enough per-operator that the existing created_at index plus a filter scan is fine; add one if a member's run list ever gets slow.

func (PG) ListSSHKeysByPrincipal added in v0.5.0

func (s PG) ListSSHKeysByPrincipal(ctx context.Context, principal string) ([]types.SSHPublicKey, error)

ListSSHKeysByPrincipal returns principal's own registered keys, newest first — the self-service GET /me/ssh-keys list.

func (PG) ListSources added in v0.5.0

func (s PG) ListSources(ctx context.Context) ([]types.Source, error)

ListSources returns the whole library, newest first.

func (PG) ListWorkspaces

func (s PG) ListWorkspaces(ctx context.Context) ([]types.Workspace, error)

ListWorkspaces returns all workspaces in reverse creation order. The slice is empty (never nil) when no workspaces exist.

func (PG) ListWorkspacesPage added in v0.3.1

func (s PG) ListWorkspacesPage(ctx context.Context, p Page) ([]types.Workspace, error)

ListWorkspacesPage returns workspaces in reverse creation order, bounded by p. workspaces_created_at_idx (0023) covers the ORDER BY.

func (PG) ListWorkspacesPageForOwner added in v0.6.0

func (s PG) ListWorkspacesPageForOwner(ctx context.Context, owner string, p Page) ([]types.Workspace, error)

ListWorkspacesPageForOwner is ListWorkspacesPage narrowed to what one member may see: their own owned rows plus every operator-owned row (” — the 0048 default, i.e. every pre-0.6 workspace). workspaces_owned_by_idx (0048) covers the IN.

func (PG) MergeWorkspaceRequirements added in v0.5.0

func (s PG) MergeWorkspaceRequirements(ctx context.Context, id uuid.UUID, add map[string]types.WorkspaceRequirement) (types.Workspace, error)

MergeWorkspaceRequirements ADDS rows to a workspace's requirements overlay atomically — the `jsonb ||` idiom SetWorkspaceRecordResult established — so the verify loop's approve-writes-the-row-now can never clobber a concurrent edit the way read-modify-write on the full-replace writer would. The WHERE clause carries the same key-count cap the PUT endpoint enforces, evaluated on the POST-merge total (existing keys merged with this patch) so a multi-key merge can't overshoot the cap in one jump; at the cap it returns ErrConflict rather than silently dropping rows or exceeding it.

func (PG) MintAttachTicket added in v0.5.0

func (s PG) MintAttachTicket(ctx context.Context, token string, t AttachTicket, now, expiresAt time.Time) error

MintAttachTicket records one outstanding ticket, expiring at expiresAt, and sweeps already-expired rows in the same statement. The sweep compares against the caller's clock (now), NOT now(): expires_at is written from the app clock and consume compares against the app clock, so a DB clock running ahead must not silently delete live tickets.

ponytail: the sweep rides the mint instead of a background worker — the table holds only unredeemed tickets inside a 30s TTL, i.e. a handful of rows. Add a sweeper (or an expires_at index) only if mint volume ever makes that false.

func (PG) Ping added in v0.6.0

func (s PG) Ping(ctx context.Context) error

Ping proves the pool can actually reach Postgres (a live query round-trip, not just a constructed pool).

func (PG) PutCapabilityEnforcement added in v0.6.0

func (s PG) PutCapabilityEnforcement(ctx context.Context, enabled map[string]bool) (map[string]bool, error)

PutCapabilityEnforcement replaces the WHOLE switch map: any capability not named in enabled loses its row (absent == not enforced, so dropping the row and writing false mean the same thing, and dropping keeps a kind the Go side no longer defines from lingering).

One statement, not a transaction: the DELETE and the INSERT run against the same snapshot inside a single CTE, they touch disjoint capability sets, and the pair is therefore already atomic without a round trip to BEGIN.

func (PG) PutRef added in v0.3.1

func (s PG) PutRef(ctx context.Context, ref, substrateName string) error

PutRef upserts the ref -> substrate-name row (ref is the primary key).

func (PG) PutRunLayout added in v0.5.0

func (s PG) PutRunLayout(ctx context.Context, principal, preset string, layout []types.RunLayoutWidget) (types.RunLayout, error)

PutRunLayout upserts principal's layout for preset. ON CONFLICT DO UPDATE, keyed on the (principal, preset) primary key, makes this the single idempotent write the "save layout" action needs — no read-then-decide (insert vs update) round trip, and no lost-update race between two tabs saving the same preset back to back.

func (PG) PutSiteConfig

func (s PG) PutSiteConfig(ctx context.Context, cfg types.SiteConfig) (types.SiteConfig, error)

PutSiteConfig upserts the single operator-wide site config row and returns the persisted value. The `singleton` primary key (CHECKed true) makes a second row impossible at the schema level; a write always REPLACES the whole document (no partial merge — the API layer decodes and validates the full document before calling this).

func (PG) QueryAuditEvents

func (s PG) QueryAuditEvents(ctx context.Context, runID uuid.UUID, limit int) ([]types.AuditEvent, error)

QueryAuditEvents returns audit events for a run in time order. limit <= 0 means no explicit limit (returns up to 1000).

func (PG) QueryAuditEventsFilteredPage added in v0.4.4

func (s PG) QueryAuditEventsFilteredPage(ctx context.Context, runID *uuid.UUID, f AuditFilter, p Page) ([]types.AuditEvent, error)

QueryAuditEventsFilteredPage returns audit events narrowed by f, bounded by p. A non-nil runID scopes to one run and keeps that trail's chronological seq ASC order (the per-run contract in docs/sdk.md); nil is the newest-first global feed. Only a request that actually sets a predicate takes this path — an unfiltered read stays on the two plain queries above, whose plans are unchanged.

No new index: 0001 covers (time) and 0017 (action, seq DESC); a bounded LIMIT query index-scans the pkey backward and filters, which is what the caps are for.

func (PG) QueryAuditEventsPage added in v0.3.1

func (s PG) QueryAuditEventsPage(ctx context.Context, runID uuid.UUID, p Page) ([]types.AuditEvent, error)

QueryAuditEventsPage returns a run's audit events in seq (chronological) order, bounded by p. audit_events_run_seq_idx (0023) makes WHERE run_id + ORDER BY seq an indexed range scan with no sort; OFFSET pages forward without flipping to DESC, so the per-run trail stays ASC (docs/sdk.md's exit-code contract) and a caller pages to the newest events with ?offset=.

func (PG) QueryRecentAuditEvents

func (s PG) QueryRecentAuditEvents(ctx context.Context, limit int) ([]types.AuditEvent, error)

QueryRecentAuditEvents returns the most-recent audit events across ALL runs, newest first — the global SIEM-style feed the Audit view renders. Per-run queries (QueryAuditEvents) stay chronological; this global tail is reverse- chronological and bounded by limit.

func (PG) QueryRecentAuditEventsPage added in v0.3.1

func (s PG) QueryRecentAuditEventsPage(ctx context.Context, p Page) ([]types.AuditEvent, error)

QueryRecentAuditEventsPage returns the newest-first global audit feed, bounded by p. seq is the audit_events PRIMARY KEY, so ORDER BY seq DESC + LIMIT is an index-scan-backward with no added index (see 0020's audit note).

func (PG) RefreshSSHKeyRoles added in v0.6.0

func (s PG) RefreshSSHKeyRoles(ctx context.Context, principal, role string, checkedAt time.Time) error

RefreshSSHKeyRoles re-stamps role AND role_checked_at on every key owned by principal — the OIDC callback's OnLogin hook (migration 0046), fired on every successful login with that login's freshly-derived role. This is what narrows the admin-override stamp from "set once at registration, never touched again" to "at most WARDYN_SSH_ROLE_TTL stale": a login is a live read of the human's CURRENT role, more authoritative than whatever was true the day a given key was registered, so it overwrites role too, not only the timestamp — a demoted human's keys downgrade to member on their very next login, and a promoted human's keys upgrade the same way, with no delete-then-re-register needed. A principal with no registered keys is a normal, silent no-op (RowsAffected 0) — logging in has nothing to refresh.

func (PG) RevokeAPIToken added in v0.6.0

func (s PG) RevokeAPIToken(ctx context.Context, id uuid.UUID, principal string, now time.Time) (types.APIToken, error)

RevokeAPIToken marks id revoked. principal scopes the UPDATE when non-empty (the self-service path, where a human may only ever revoke their OWN token and someone else's id is ErrNotFound rather than a distinguishable 403 — no existence leak across principals); an EMPTY principal is the ADMIN path and revokes anyone's.

Already-revoked is ErrNotFound too (`revoked_at IS NULL` in the WHERE): revoke is idempotent in effect, and a second call must not emit a second `token.revoke` audit row for an act that did not happen.

func (PG) RunWatcherFresh added in v0.5.0

func (s PG) RunWatcherFresh(ctx context.Context, id uuid.UUID, staleAfter time.Duration) (bool, error)

RunWatcherFresh reports whether id's watcher lease is younger than staleAfter — mirrors ClaimStaleRunWatchers' own staleness predicate (a live watcher owns it). A missing row → (false, nil): nothing is watching it, so the reaper may proceed.

func (PG) SetRunAgentExecID added in v0.3.1

func (s PG) SetRunAgentExecID(ctx context.Context, id uuid.UUID, execID string) error

SetRunAgentExecID scoped-writes ONLY the agent_exec_id column. Called once right after the driver execs the agent (the exec id exists only after Exec, so this is a scoped update, not a CreateRun column value). The crash reconciler reads it to observe agent liveness across a restart.

func (PG) SetRunFailureHint added in v0.6.0

func (s PG) SetRunFailureHint(ctx context.Context, id uuid.UUID, hint string) error

SetRunFailureHint scoped-writes ONLY the failure_hint column — the one-line operator reason a run FAILED before its agent started (D9). Mirrors SetRunImage/SetRunAgentExecID: the hint is known only at the failure site (failAndRevoke), after the row exists, so it is a scoped update, not a CreateRun value. Best-effort at the call site; ErrNotFound when no row matched.

func (PG) SetRunImage added in v0.2.0

func (s PG) SetRunImage(ctx context.Context, id uuid.UUID, image string) error

SetRunImage scoped-writes ONLY the resolved-image provenance column. Called once after image resolution (the image is resolved after the row is inserted, so this is a scoped update, not a CreateRun column).

func (PG) SetSandboxRef

func (s PG) SetSandboxRef(ctx context.Context, id uuid.UUID, ref string) error

SetSandboxRef records the runner reference (container ID / pod name).

func (PG) SetSourceScanResult added in v0.5.0

func (s PG) SetSourceScanResult(ctx context.Context, id uuid.UUID, profile []byte, status types.WorkspaceStatus, runID uuid.UUID, seed map[string]types.WorkspaceRequirement) (types.Source, error)

SetSourceScanResult persists a scan outcome FENCED on the claiming run: only the run that holds active_run_id may write, so a stale upload from a superseded run can never clobber a fresher result. `seed` is the scan's requirement discovery for the SOURCE's OWN contract, applied as a provenance-aware REBUILD in the same statement: seed replaces the scan_seeded subset of requirements outright (so a name a rescan no longer finds is DROPPED, not stuck forever); non-scan_seeded rows — an operator's own edit — always win regardless of seed, because they land on the RIGHT side of jsonb `||`; a NULL seed (failed scan — workspace_run.go's launch-failure path passes nil) leaves the contract untouched. A non-nil but EMPTY seed (a rescan that legitimately finds nothing) still rebuilds: it marshals to '{}', not NULL — see sourceRequirementsParam.

func (PG) SetSourceScanResultUnfenced added in v0.5.0

func (s PG) SetSourceScanResultUnfenced(ctx context.Context, id uuid.UUID, profile []byte, status types.WorkspaceStatus, seed map[string]types.WorkspaceRequirement) (types.Source, error)

SetSourceScanResultUnfenced persists a SYNCHRONOUS (inline local_dir) scan, which never claimed a run slot — there is no concurrent writer to fence against on that path, exactly as the workspace inline scan wrote directly. Same provenance-aware rebuild semantics as the fenced writer: rebuilds the scan_seeded subset; non-scan_seeded rows still win; NULL seed (failed scan) leaves the contract untouched.

func (PG) SetWorkspaceApprovedEgress

func (s PG) SetWorkspaceApprovedEgress(ctx context.Context, id uuid.UUID, domains []string) (types.Workspace, error)

SetWorkspaceApprovedEgress replaces ONLY the operator-owned approved-egress column (plus updated_at), returning the updated row. Scoped on purpose: an approval must never clobber a concurrently-persisted scan (an async repo scan's profile/status land via the full-column UpdateWorkspace, and a read-modify-write here would silently revert them).

func (PG) SetWorkspaceBuiltImage

func (s PG) SetWorkspaceBuiltImage(ctx context.Context, id uuid.UUID, imageRef, builtHash string) (types.Workspace, error)

SetWorkspaceBuiltImage scoped-writes ONLY the image cache columns (the build-once/reuse-many cache) — the anti-clobber discipline the other scoped writers established; the previous full-row cache write could revert every concurrently-persisted async field from a stale snapshot.

func (PG) SetWorkspaceDeniedEgress added in v0.5.0

func (s PG) SetWorkspaceDeniedEgress(ctx context.Context, id uuid.UUID, domains []string) (types.Workspace, error)

SetWorkspaceDeniedEgress replaces ONLY the operator-owned denied-egress column (plus updated_at), returning the updated row — denied_egress's mirror of SetWorkspaceApprovedEgress, backing the Phase 4 revocation PUT. Same anti-clobber discipline: a full-list replace can never clobber a concurrently-persisted async scan. Pass the FULL desired list — like SetWorkspaceApprovedEgress, this replaces rather than merges.

func (PG) SetWorkspaceImportState

func (s PG) SetWorkspaceImportState(ctx context.Context, id uuid.UUID,
	status types.WorkspaceStatus, activeRunID *uuid.UUID, expectedActive *uuid.UUID) (types.Workspace, bool, error)

SetWorkspaceImportState is the scoped writer the scan orchestrator uses to advance status + the in-flight run pointer without a full-row read-modify- write.

FENCED (same shape as ClaimSourceActiveRun / SetSourceScanResult): the write is conditional on the import-step slot still holding expectedActive, so a caller that decided what to write from a STALE read cannot land it. Every caller here does check-then- act (read the workspace, decide, write), and this was the only unfenced workspace writer — a finalize/update racing a live scan/record run could overwrite the fresher state the concurrent run had just written, which is exactly the class of race the C001 finalize guard closed at one call site only. Pass expectedActive = the active_run_id observed in the read the decision came from (nil means "expected no in-flight run"); applied=false means the slot moved under the caller, which must then re-read rather than retry blindly. Returns ErrNotFound only when the workspace does not exist.

func (PG) SetWorkspaceLLMCred added in v0.4.0

func (s PG) SetWorkspaceLLMCred(ctx context.Context, id uuid.UUID, cred *types.WorkspaceLLMCred) (types.Workspace, error)

SetWorkspaceLLMCred replaces ONLY the operator-owned model/harness cred binding column (plus updated_at), returning the updated row. Scoped like SetWorkspaceApprovedEgress so it can never clobber a concurrently-persisted async scan. Pass nil to clear the binding.

func (PG) SetWorkspaceOwner added in v0.6.0

func (s PG) SetWorkspaceOwner(ctx context.Context, id uuid.UUID, owner string) (types.Workspace, error)

SetWorkspaceOwner replaces ONLY the owned_by column (plus updated_at), returning the updated row — the offboarding reassign (decision O6). Scoped like SetWorkspaceLLMCred above, and deliberately the ONLY writer of the column after CreateWorkspace stamps it: UpdateWorkspace's column list omits owned_by, so an ordinary workspace edit can never move ownership.

func (PG) SetWorkspaceRecordResult

func (s PG) SetWorkspaceRecordResult(ctx context.Context, id uuid.UUID,
	taskKey string, result json.RawMessage, onlyIfStatus string) (types.Workspace, bool, error)

SetWorkspaceRecordResult atomically upserts ONE task's entry in the Record Mode record_results map (jsonb || merge — never a whole-map read-modify- write, so concurrent writers of DIFFERENT tasks can never lose each other's entries). When onlyIfStatus is non-empty the write applies only while the task's CURRENT stored status equals it (single-statement compare-and-set): a late streaming upload can never revert a completed capture, and a double capture no-ops. Returns applied=false (no error) on a guard miss.

func (PG) SetWorkspaceRequirements added in v0.5.0

func (s PG) SetWorkspaceRequirements(ctx context.Context, id uuid.UUID, reqs map[string]types.WorkspaceRequirement) (types.Workspace, error)

SetWorkspaceRequirements replaces ONLY the requirements-contract column (plus updated_at), returning the updated row. Scoped write, same anti-clobber discipline as SetWorkspaceApprovedEgress: it can never clobber a concurrently-persisted async scan (profile/status land via the full-column UpdateWorkspace, and a read-modify-write here would silently revert them). Pass the FULL desired map — like SetWorkspaceApprovedEgress, this replaces rather than merges; a caller adding one requirement to an existing set reads first, merges in Go, then calls this with the result.

func (PG) TouchAPIToken added in v0.6.0

func (s PG) TouchAPIToken(ctx context.Context, id uuid.UUID, now time.Time) error

TouchAPIToken records that id was just used. BEST EFFORT by contract: the auth branch ignores the error, because failing to record a touch must never fail an otherwise-valid request.

ponytail: one UPDATE per authenticated token request. API tokens serve scripts and CI, not a browser's request storm, so the write volume is the caller's own call rate — add a coarse `AND last_used_at < now() - interval` throttle only if a hot token ever makes that false.

func (PG) TouchRun

func (s PG) TouchRun(ctx context.Context, id uuid.UUID) error

TouchRun bumps a run's updated_at to now() without changing any other field. It is the activity keepalive the interactive-attach handler calls so the idle reaper (which measures idleness by agent_runs.updated_at) does not stop a run that a human is actively attached to. Returns ErrNotFound when no row matched.

func (PG) UpdateBaseImageName added in v0.5.0

func (s PG) UpdateBaseImageName(ctx context.Context, id uuid.UUID, name string) (types.BaseImageEntry, error)

UpdateBaseImageName renames a catalog base-image row — the explicit, scoped rename path (W7-S1-3), mirroring UpdateSourceConfig above. handleCreateBaseImage is the only caller: on an identity hit where the REQUEST carried an explicit name (the Add dialog's re-POST-to-rename shape), never from UpsertBaseImage's own conflict clause, which passthrough callers share and must never let rename an operator's chosen name away from under them (see UpsertBaseImage's doc).

func (PG) UpdatePolicy

func (s PG) UpdatePolicy(ctx context.Context, id uuid.UUID, name string, spec types.RunPolicySpec) (types.RunPolicy, error)

UpdatePolicy replaces a policy's name and spec and bumps updated_at, returning the persisted row. Returns ErrNotFound when no policy has the given id. The caller is responsible for validating the spec before calling (policies are admin-gated config; the API validates every spec before it reaches the store).

func (PG) UpdateRunStateIf

func (s PG) UpdateRunStateIf(ctx context.Context, id uuid.UUID, fromState, toState types.RunState) (bool, error)

UpdateRunStateIf conditionally transitions a run from fromState to toState in a single UPDATE ... WHERE id=$ AND state=$from, returning whether the update applied. It is the optimistic guard the completion watcher uses: it only transitions a run that is STILL in fromState (e.g. RUNNING), so a concurrent kill/stop that already moved the run to a terminal state is never clobbered (TOCTOU-safe, like DecideApproval). A false return with a nil error means the run existed but was no longer in fromState (or did not exist) — the caller treats this as "someone else won the transition" and does nothing.

func (PG) UpdateRunStateIfIdle

func (s PG) UpdateRunStateIfIdle(ctx context.Context, id uuid.UUID, fromState, toState types.RunState, notAfter time.Time) (bool, error)

UpdateRunStateIfIdle is UpdateRunStateIf plus an idleness guard: it transitions a run from fromState to toState ONLY when the row is still in fromState AND its updated_at has NOT advanced past notAfter (the snapshot the caller observed). This closes the reaper's idleness TOCTOU: the idle scan reads updated_at in a snapshot, but an active `wardyn attach` TouchRun (which bumps updated_at while leaving state=RUNNING) can land between snapshot and stop. Guarding only on state=RUNNING would then stop the now-active run, defeating the keepalive. Passing the snapshot's updated_at as notAfter makes a run touched after the snapshot no-op the stop (rows-affected 0 => false), so the reaper leaves it be and retries on the next tick. Returns (true, nil) when the transition applied.

func (PG) UpdateSourceConfig added in v0.5.0

func (s PG) UpdateSourceConfig(ctx context.Context, id uuid.UUID, name string, reqs map[string]types.WorkspaceRequirement) (types.Source, error)

UpdateSourceConfig replaces a source's operator-editable fields (name + its OWN requirements contract) — scoped, never touching the scan-owned columns, in the SetWorkspaceRequirements tradition.

func (PG) UpdateWorkspace

func (s PG) UpdateWorkspace(ctx context.Context, id uuid.UUID, ws types.Workspace) (types.Workspace, error)

UpdateWorkspace replaces a workspace's editable composition (name, sources, base_image, requirements) and bumps updated_at, returning the persisted row. It is a FULL-column write (it also sets profile, image_ref, built_profile_hash, status and the other scan-owned columns) — WITH ONE DELIBERATE EXCEPTION: denied_egress is NOT in this SET clause, and must stay that way (see Workspace.DeniedEgress) — a permanent deny is an operator decision that survives a composition edit, unlike ApprovedEgress below. Every other column here is why callers must round-trip the fetched row. Returns ErrNotFound when no workspace has the given id.

handleUpdateWorkspace does round-trip, and resets the scan-owned fields + ApprovedEgress itself when the composition changed — the persisted profile and egress approvals were reviewed against the OLD sources.

func (PG) UpsertBaseImage added in v0.5.0

func (s PG) UpsertBaseImage(ctx context.Context, b types.BaseImageEntry) (types.BaseImageEntry, error)

UpsertBaseImage inserts a catalog image or returns the row with the same identity (kind, image, steps) — the identity index is the dedupe rule. The CHECK constraint refuses 'recommended' structurally: that build is derived per-workspace and has no catalog identity.

An identity hit does NOT touch name (same as UpsertSource's conflict clause below) — on purpose. This upsert is also the PASSTHROUGH path a workspace/run resolves its declared base-image spec through (sources.go's attachSourcesAndBaseImage-shaped callers), which always derives an auto-placeholder name (lastPathSegment(image)) with no rename intent whatsoever; if this conflict clause applied EXCLUDED.name unconditionally, every such passthrough call would silently rename an operator's custom-named catalog row back to that placeholder. See UpdateBaseImageName for the actual rename path (W7-S1-3).

func (PG) UpsertCapabilityGrant added in v0.6.0

func (s PG) UpsertCapabilityGrant(ctx context.Context, g types.CapabilityGrant) (types.CapabilityGrant, error)

UpsertCapabilityGrant writes one grant, keyed on the natural (subject_type, subject, capability, value) UNIQUE: re-granting the same triple FLIPS the effect in place rather than leaving two contradictory rows behind (two rows would resolve as a permanent deny — deny beats allow — and be near-impossible for an admin to explain, let alone undo).

The returned row carries the row's real id, which on a conflict is the EXISTING one, not g.ID: the caller needs the id the DELETE route will be given, and an admin re-submitting the same grant must not be handed an id that names no row.

func (PG) UpsertSource added in v0.5.0

func (s PG) UpsertSource(ctx context.Context, src types.Source) (types.Source, error)

UpsertSource inserts a library source or returns the existing row with the same identity (kind, locator, ref) — ONE statement, no read-then-write race: the UNIQUE constraint IS the dedupe rule. The no-op DO UPDATE lets RETURNING yield the surviving row either way. Identity fields must arrive CANONICALIZED (the api layer owns that: dirs trim trailing slashes, repo locators lowercase, refs trimmed) — the store stores what it is given.

func (PG) VerifyAuditChain added in v0.6.0

func (s PG) VerifyAuditChain(ctx context.Context) (AuditChainStatus, error)

VerifyAuditChain walks the audit_events hash chain oldest-first, re-hashing every row with migration 0047's audit_row_hash — the SAME function the insert trigger used to write it, so there is no second implementation to drift out of agreement with the first.

Rows are STREAMED, not buffered: an audit log is unbounded, and this is the one query in the package that reads all of it.

func (PG) WorkspacesAttaching added in v0.5.0

func (s PG) WorkspacesAttaching(ctx context.Context, id uuid.UUID) ([]string, error)

WorkspacesAttaching returns the names of workspaces whose attachments reference source id — the loud half of delete-in-use. One GIN probe (workspaces_attachments_gin). A dangling attachment would silently narrow a workspace to its remaining sources (no error, no mount-gate check for a source that used to be there), so DELETE refuses with these names rather than orphaning silently.

func (PG) WorkspacesUsingBaseImage added in v0.5.0

func (s PG) WorkspacesUsingBaseImage(ctx context.Context, id uuid.UUID) ([]string, error)

WorkspacesUsingBaseImage is the catalog's delete-in-use check.

type Page added in v0.3.1

type Page struct {
	Limit  int
	Offset int
}

Page bounds a List query to Limit rows after skipping Offset, ordered by the query's own ORDER BY. A zero or negative Limit means UNBOUNDED — the historical List* behaviour the internal callers depend on (ReconcileOnBoot's stranded-run scan, the create-run workspace-collision scan, the approval fan-out) all need the whole table, so they call the plain List* wrappers below. The public read handlers pass an explicit Limit (capped by api.parseListPage) via the *Page methods so an external client can never pull down an unbounded payload.

type Pager added in v0.3.1

type Pager interface {
	ListRunsPage(ctx context.Context, p Page) ([]types.AgentRun, error)
	ListPoliciesPage(ctx context.Context, p Page) ([]types.RunPolicy, error)
	ListWorkspacesPage(ctx context.Context, p Page) ([]types.Workspace, error)
	ListApprovalsPage(ctx context.Context, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)
	QueryAuditEventsPage(ctx context.Context, runID uuid.UUID, p Page) ([]types.AuditEvent, error)
	QueryRecentAuditEventsPage(ctx context.Context, p Page) ([]types.AuditEvent, error)
	// QueryAuditEventsFilteredPage serves a NARROWED audit read (time range,
	// action, outcome, actor type); see AuditFilter in auditfilter.go. An
	// unfiltered read still uses the two methods above.
	QueryAuditEventsFilteredPage(ctx context.Context, runID *uuid.UUID, f AuditFilter, p Page) ([]types.AuditEvent, error)
}

Pager is the paginated read surface. It is deliberately NOT part of the Store interface: the control plane has many test doubles that embed store.Store and override a handful of methods, and widening Store would silently route their list calls to the embedded nil interface. Handlers type-assert s.cfg.Store to Pager and fall back to the unbounded List* + in-Go windowing when a store (a test fake) does not implement it. Production always uses PG, which does.

type Recorder

type Recorder struct {
	Pool *pgxpool.Pool
}

Recorder wraps a pool and implements audit.Recorder via InsertAuditEvent. This satisfies the assignment: "internal/store implements audit.Recorder (Record == InsertAuditEvent)".

func (Recorder) Record

func (rec Recorder) Record(ctx context.Context, ev types.AuditEvent) error

Record appends ev to the append-only audit_events table.

The chain hashes InsertAuditEvent fills in are DROPPED here: audit.Recorder takes ev by value, so there is nowhere to hand them back. A caller that wants the head hash — cmd/wardynd's fanoutRecorder, which forwards it to the audit sinks — calls InsertAuditEvent directly with its own &ev.

type RunLayoutStore added in v0.5.0

type RunLayoutStore interface {
	// GetRunLayout returns principal's saved layout for preset, or
	// ErrNotFound when nothing has been saved yet — the api layer treats
	// that as the empty/default shape, not an error (a human who has never
	// customized the cockpit does not get a failure).
	GetRunLayout(ctx context.Context, principal, preset string) (types.RunLayout, error)
	// PutRunLayout upserts principal's layout for preset and returns the
	// stored row, including the server-assigned updated_at.
	PutRunLayout(ctx context.Context, principal, preset string, layout []types.RunLayoutWidget) (types.RunLayout, error)
}

RunLayoutStore is the run-cockpit widget-layout persistence surface. Like Pager (pagination.go) and RunWatcherLeaser (store_watcher.go), it is deliberately NOT part of the Store interface: the control plane has ~30 test doubles that embed store.Store and override a handful of methods, so widening Store would route a layout read/write to each fake's embedded nil interface instead of a real implementation — breaking every one of those doubles for a feature they have nothing to do with. The api layer type-asserts s.cfg.Store to RunLayoutStore and degrades when it is absent (GET returns the empty/default shape, PUT 501s) rather than widening Store; production is always PG, which has it.

type RunWatcherLeaser added in v0.5.0

type RunWatcherLeaser interface {
	ClaimStaleRunWatchers(ctx context.Context, owner string, staleAfter time.Duration) ([]types.AgentRun, error)
	HeartbeatRunWatcher(ctx context.Context, id uuid.UUID, owner string) error
	// RunWatcherFresh reports whether run id's watcher lease is still fresh (its
	// heartbeat is younger than staleAfter) — a live process is responsible for it.
	// The undispatched-run reaper consults it so it never false-fails a RUNNING run
	// whose sandbox_ref write was merely lost but whose live watcher is holding the
	// lease (GAP-RECONCILE-4). A missing row reads as NOT fresh (reap it).
	RunWatcherFresh(ctx context.Context, id uuid.UUID, staleAfter time.Duration) (bool, error)
}

RunWatcherLeaser is the watcher-lease surface. Like Pager (pagination.go) and for the same reason, it is deliberately NOT part of the Store interface: the control plane has ~30 test doubles that embed store.Store and override a handful of methods, so widening Store would route their lease writes to the embedded nil interface — and a lease write happens inside the watcher goroutine, whose own recover() would swallow the panic and kill the watcher silently. The api layer type-asserts and simply does not lease when a store lacks the surface; production is always PG, which has it.

type RunsByCreatorPager added in v0.5.0

type RunsByCreatorPager interface {
	ListRunsPageByCreator(ctx context.Context, createdBy string, p Page) ([]types.AgentRun, error)
}

RunsByCreatorPager is the ownership-scoped analogue of Pager.ListRunsPage: a member's GET /runs is scoped to created_by = the caller (internal/api's isOperator decides who is a member). Kept OUT of Pager for the same reason Pager is kept out of Store (widening either silently reroutes a test fake's embedded-but-not-overridden method to the wrong behavior) — AND for a second, stronger reason specific to this one: Pager's own absence falls back to a SAFE fetch-all + in-Go window (nothing is scoped, so an unscoped fallback changes nothing). This interface's absence must never fall back that way — an unscoped list IS the vulnerability for a member — so the api-layer call site fails closed (a clear 500) when a store does not implement it, rather than silently serving every run.

type Store

type Store interface {
	// AgentRun.
	CreateRun(ctx context.Context, r types.AgentRun) (types.AgentRun, error)
	GetRun(ctx context.Context, id uuid.UUID) (types.AgentRun, error)
	ListRuns(ctx context.Context) ([]types.AgentRun, error)
	UpdateRunStateIf(ctx context.Context, id uuid.UUID, fromState, toState types.RunState) (bool, error)
	UpdateRunStateIfIdle(ctx context.Context, id uuid.UUID, fromState, toState types.RunState, notAfter time.Time) (bool, error)
	SetSandboxRef(ctx context.Context, id uuid.UUID, ref string) error
	SetRunImage(ctx context.Context, id uuid.UUID, image string) error
	SetRunAgentExecID(ctx context.Context, id uuid.UUID, execID string) error
	TouchRun(ctx context.Context, id uuid.UUID) error

	// RunPolicy.
	CreatePolicy(ctx context.Context, p types.RunPolicy) (types.RunPolicy, error)
	GetPolicy(ctx context.Context, id uuid.UUID) (types.RunPolicy, error)
	ListPolicies(ctx context.Context) ([]types.RunPolicy, error)
	UpdatePolicy(ctx context.Context, id uuid.UUID, name string, spec types.RunPolicySpec) (types.RunPolicy, error)
	DeletePolicy(ctx context.Context, id uuid.UUID) error

	// Workspace.
	CreateWorkspace(ctx context.Context, ws types.Workspace) (types.Workspace, error)
	GetWorkspace(ctx context.Context, id uuid.UUID) (types.Workspace, error)
	ListWorkspaces(ctx context.Context) ([]types.Workspace, error)
	UpdateWorkspace(ctx context.Context, id uuid.UUID, ws types.Workspace) (types.Workspace, error)
	SetWorkspaceApprovedEgress(ctx context.Context, id uuid.UUID, domains []string) (types.Workspace, error)
	// AddWorkspaceEgressDecision records one `always`-scoped egress decision:
	// on allow, host is added to approved_egress (capped at maxApprovedEgress,
	// deduped) and removed from denied_egress; on deny the mirror. Returns
	// ErrConflict (not ErrNotFound) when id exists but the cap refused the
	// write. See store.go for the full contract.
	AddWorkspaceEgressDecision(ctx context.Context, id uuid.UUID, host string, allow bool, maxApprovedEgress int) (types.Workspace, error)
	// SetWorkspaceDeniedEgress is SetWorkspaceApprovedEgress's mirror for the
	// operator-owned denied-egress list (Phase 4 revocation PUT): pass the
	// FULL desired list, replacing rather than merging.
	SetWorkspaceDeniedEgress(ctx context.Context, id uuid.UUID, domains []string) (types.Workspace, error)
	SetWorkspaceLLMCred(ctx context.Context, id uuid.UUID, cred *types.WorkspaceLLMCred) (types.Workspace, error)
	// SetWorkspaceOwner replaces ONLY the owned_by column (plus updated_at).
	// The offboarding path (design decision O6): an admin reassigns a departed
	// member's workspace to the operator by setting owner "". Scoped for the
	// same reason SetWorkspaceLLMCred is — it must never replay a stale
	// snapshot over a concurrently-persisted async scan — and separate from
	// UpdateWorkspace on purpose: the full-row update deliberately does not
	// carry owned_by, so no ordinary edit can move ownership.
	SetWorkspaceOwner(ctx context.Context, id uuid.UUID, owner string) (types.Workspace, error)
	SetWorkspaceRequirements(ctx context.Context, id uuid.UUID, reqs map[string]types.WorkspaceRequirement) (types.Workspace, error)
	SetWorkspaceRecordResult(ctx context.Context, id uuid.UUID, taskKey string, result json.RawMessage, onlyIfStatus string) (types.Workspace, bool, error)
	ClaimWorkspaceActiveRun(ctx context.Context, id, runID uuid.UUID, expected *uuid.UUID) (types.Workspace, bool, error)
	ClearWorkspaceActiveRun(ctx context.Context, id, runID uuid.UUID) (bool, error)
	SetWorkspaceBuiltImage(ctx context.Context, id uuid.UUID, imageRef, builtHash string) (types.Workspace, error)
	// SetWorkspaceImportState advances the scan pipeline. FENCED: the write
	// applies only while the import-step slot still holds expectedActive (nil =
	// expected empty); applied=false means it moved and the caller must re-read
	// instead of retrying blindly.
	SetWorkspaceImportState(ctx context.Context, id uuid.UUID, status types.WorkspaceStatus, activeRunID *uuid.UUID, expectedActive *uuid.UUID) (types.Workspace, bool, error)
	// MergeWorkspaceRequirements ADDS overlay rows atomically (jsonb ||) — the
	// verify loop's approve-writes-the-row-now, safe against concurrent edits
	// the full-replace SetWorkspaceRequirements would race. ErrConflict at the
	// key cap.
	MergeWorkspaceRequirements(ctx context.Context, id uuid.UUID, add map[string]types.WorkspaceRequirement) (types.Workspace, error)
	DeleteWorkspace(ctx context.Context, id uuid.UUID) error

	// Source library (tier 1) — a repo/dir configured once, attached to many
	// workspaces. Upsert dedupes on (kind, locator, ref); the scan lifecycle
	// (fence, result) mirrors the workspace's own pre-split shape.
	UpsertSource(ctx context.Context, src types.Source) (types.Source, error)
	GetSource(ctx context.Context, id uuid.UUID) (types.Source, error)
	GetSourcesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]types.Source, error)
	GetBaseImagesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]types.BaseImageEntry, error)
	ListSources(ctx context.Context) ([]types.Source, error)
	UpdateSourceConfig(ctx context.Context, id uuid.UUID, name string, reqs map[string]types.WorkspaceRequirement) (types.Source, error)
	WorkspacesAttaching(ctx context.Context, id uuid.UUID) ([]string, error)
	DeleteSource(ctx context.Context, id uuid.UUID, detach bool) error
	ClaimSourceActiveRun(ctx context.Context, id, runID uuid.UUID) error
	ClearSourceActiveRun(ctx context.Context, id, runID uuid.UUID) error
	SetSourceScanResult(ctx context.Context, id uuid.UUID, profile []byte, status types.WorkspaceStatus, runID uuid.UUID, seed map[string]types.WorkspaceRequirement) (types.Source, error)
	SetSourceScanResultUnfenced(ctx context.Context, id uuid.UUID, profile []byte, status types.WorkspaceStatus, seed map[string]types.WorkspaceRequirement) (types.Source, error)

	// Base-image catalog (tier 2). Upsert dedupes on (kind, image, steps);
	// "recommended" is structurally excluded (CHECK) — it is a per-workspace
	// derived build, never a catalog row.
	UpsertBaseImage(ctx context.Context, b types.BaseImageEntry) (types.BaseImageEntry, error)
	// UpdateBaseImageName renames a catalog row (W7-S1-3) — the operator-editable
	// counterpart to UpdateSourceConfig above, deliberately NOT folded into
	// UpsertBaseImage's identity-hit dedupe. See both doc comments.
	UpdateBaseImageName(ctx context.Context, id uuid.UUID, name string) (types.BaseImageEntry, error)
	GetBaseImage(ctx context.Context, id uuid.UUID) (types.BaseImageEntry, error)
	ListBaseImages(ctx context.Context) ([]types.BaseImageEntry, error)
	WorkspacesUsingBaseImage(ctx context.Context, id uuid.UUID) ([]string, error)
	DeleteBaseImage(ctx context.Context, id uuid.UUID, detach bool) error

	// CredentialGrant.
	CreateGrant(ctx context.Context, g types.CredentialGrant) (types.CredentialGrant, error)
	ListGrantsByRun(ctx context.Context, runID uuid.UUID) ([]types.CredentialGrant, error)

	// ApprovalRequest.
	CreateApproval(ctx context.Context, a types.ApprovalRequest) (types.ApprovalRequest, error)
	GetApproval(ctx context.Context, id uuid.UUID) (types.ApprovalRequest, error)
	ListApprovals(ctx context.Context, stateFilter types.ApprovalState) ([]types.ApprovalRequest, error)
	// DecideApproval transitions state from PENDING to decision.State; returns
	// ErrAlreadyDecided if the approval is not PENDING. See types.ApprovalDecision.
	DecideApproval(ctx context.Context, id uuid.UUID, decision types.ApprovalDecision) (types.ApprovalRequest, error)

	// AuditEvent.
	QueryAuditEvents(ctx context.Context, runID uuid.UUID, limit int) ([]types.AuditEvent, error)
	QueryRecentAuditEvents(ctx context.Context, limit int) ([]types.AuditEvent, error)
	LatestAuditEventByAction(ctx context.Context, action string) (types.AuditEvent, error)

	// SiteConfig.
	GetSiteConfig(ctx context.Context) (types.SiteConfig, error)
	PutSiteConfig(ctx context.Context, cfg types.SiteConfig) (types.SiteConfig, error)

	// Sandbox ref -> substrate routing (the orchestrator's RefStore seam; see
	// store_sandbox_ref.go). GetRef reports a missing row as found=false, nil.
	PutRef(ctx context.Context, ref, substrateName string) error
	GetRef(ctx context.Context, ref string) (substrateName string, found bool, err error)
	DeleteRef(ctx context.Context, ref string) error

	// Short-lived cross-process handoff row (see store_ephemeral.go): single-use
	// WS attach tickets. Consume-once, and the consumer is a single
	// DELETE ... RETURNING, so two racing redemptions — on one control plane or
	// two — can only have one win.
	MintAttachTicket(ctx context.Context, token string, t AttachTicket, now, expiresAt time.Time) error
	ConsumeAttachTicket(ctx context.Context, token string, now time.Time) (AttachTicket, bool, error)

	// SSH gateway key registry (migration 0033, self-service via
	// /api/v1/me/ssh-keys). AddSSHKey returns ErrConflict when the fingerprint
	// (the PK) is already registered — by this principal or another; a given
	// key material maps to exactly one owner. DeleteSSHKey is scoped to
	// principal (an attempted delete of someone else's key is ErrNotFound, not
	// a distinguishable 403 — no existence leak). GetSSHKeyByFingerprint is the
	// gateway's auth-time lookup (unscoped: the caller has not authenticated
	// yet, that IS what this call resolves). RefreshSSHKeyRoles re-stamps
	// role+role_checked_at (migration 0046) on every key owned by principal —
	// the OIDC callback's OnLogin hook, bounding the admin-override stamp's
	// staleness instead of leaving it fixed at registration time forever.
	AddSSHKey(ctx context.Context, k types.SSHPublicKey) (types.SSHPublicKey, error)
	ListSSHKeysByPrincipal(ctx context.Context, principal string) ([]types.SSHPublicKey, error)
	GetSSHKeyByFingerprint(ctx context.Context, fingerprint string) (types.SSHPublicKey, error)
	DeleteSSHKey(ctx context.Context, fingerprint, principal string) error
	RefreshSSHKeyRoles(ctx context.Context, principal, role string, checkedAt time.Time) error

	// Per-user API tokens (migration 0045, self-service via /api/v1/me/tokens
	// and admin-wide via /api/v1/tokens). These ARE part of Store for the same
	// reason the capability methods below are: GetAPITokenByRaw runs on the
	// REQUEST PATH of every route in the authenticated group (it is the third
	// auth branch — see apiTokenAuth in internal/api/apitokens.go), so a
	// store that cannot answer it must be a COMPILE error, never a
	// degrade-to-allow type-assert hiding in a test double.
	//
	// CreateAPIToken and GetAPITokenByRaw take the PLAINTEXT token and hash it
	// internally — the raw value never reaches SQL. GetAPITokenByRaw is the
	// auth-time lookup (unscoped: the caller has not authenticated yet, that IS
	// what this call resolves) and returns ErrNotFound for unknown, mismatched
	// AND revoked tokens alike, so the boundary is not an existence oracle.
	// RevokeAPIToken is principal-scoped when principal is non-empty (the
	// self-service path; someone else's id is ErrNotFound, not a
	// distinguishable 403) and revokes ANY token when it is empty (the admin
	// path). TouchAPIToken is best effort — its error must never fail a request.
	CreateAPIToken(ctx context.Context, t types.APIToken, raw string) (types.APIToken, error)
	GetAPITokenByRaw(ctx context.Context, raw string) (types.APIToken, error)
	TouchAPIToken(ctx context.Context, id uuid.UUID, now time.Time) error
	ListAPITokensByPrincipal(ctx context.Context, principal string) ([]types.APIToken, error)
	ListAPITokens(ctx context.Context) ([]types.APIToken, error)
	RevokeAPIToken(ctx context.Context, id uuid.UUID, principal string, now time.Time) (types.APIToken, error)

	// Capability grants and the per-kind enforcement switch (migration 0042,
	// store_capabilities.go). These ARE part of Store — unlike RunLayoutStore /
	// Pager, which stayed out of it precisely so an embedded-nil test double
	// would not route to a nil interface — because the resolver runs on the
	// REQUEST PATH of routes every one of those doubles already serves. A
	// type-assert-and-degrade seam there would mean "this fake does not
	// implement capabilities, therefore allow", which is a fail-OPEN authz
	// gate hiding in a test-only branch. Widening Store makes a store that
	// cannot answer a permission question a COMPILE error instead.
	UpsertCapabilityGrant(ctx context.Context, g types.CapabilityGrant) (types.CapabilityGrant, error)
	DeleteCapabilityGrant(ctx context.Context, id uuid.UUID) error
	ListCapabilityGrants(ctx context.Context) ([]types.CapabilityGrant, error)
	// ListCapabilityGrantsFor returns the grants that could apply to one caller:
	// the `all` rows plus the `user` rows naming any of users (sub AND email)
	// plus the `group` rows naming any of groups. Not filtered by capability —
	// see the implementation's doc comment.
	ListCapabilityGrantsFor(ctx context.Context, users, groups []string) ([]types.CapabilityGrant, error)
	// GetCapabilityEnforcement returns the sparse per-kind switch map; an absent
	// key means NOT enforced, which is the zero-config back-compat default.
	GetCapabilityEnforcement(ctx context.Context) (map[string]bool, error)
	// PutCapabilityEnforcement replaces the WHOLE map (a capability the caller
	// omits loses its row) and returns the stored result.
	PutCapabilityEnforcement(ctx context.Context, enabled map[string]bool) (map[string]bool, error)

	// Ping proves the store is actually reachable, not just constructed — the
	// /readyz readiness probe's one call. A live TCP connect with no working
	// query would otherwise read as healthy forever.
	Ping(ctx context.Context) error
}

Store is the abstract persistence seam the control plane talks to. The default Postgres implementation is PG, whose methods hold the query bodies directly (no pool param — the receiver carries its own handle); a future pure-Go SQLite backend will satisfy this same interface without touching the API layer.

Out of scope on purpose: the transactional surfaces (broker mint FOR UPDATE, identity revocation) need a real transaction rather than a single-call store and stay on the pool directly.

type WorkspacesByOwnerPager added in v0.6.0

type WorkspacesByOwnerPager interface {
	ListWorkspacesPageForOwner(ctx context.Context, owner string, p Page) ([]types.Workspace, error)
}

WorkspacesByOwnerPager is the ownership-scoped analogue of Pager.ListWorkspacesPage: a MEMBER's GET /workspaces sees their OWN owned rows plus the operator-owned ones (owned_by = ”), never another member's.

Unlike RunsByCreatorPager, an absent implementation here is NOT a fail-closed case: the api-layer fallback fetches all and applies the SAME owned_by filter in Go before windowing, so the scoping still holds — only the LIMIT/OFFSET moves out of the database. Kept out of Pager for the usual reason (a test fake embedding Store must not silently inherit it).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL