store

package
v0.7.13 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Governance profiles and their subject assignments (migration 0052). Kept out of store.go on purpose, mirroring store_capabilities.go's split.

This file is DUMB on purpose: it round-trips rows and owns exactly one piece of logic, ResolveGovernanceProfile's ORDER BY, because that precedence has to be a property of the single indexed read rather than of a caller that could forget it. Every write is validated at the API boundary (internal/api/ governance.go) — the ceiling through validatePolicySpec, the eligible grants through the monotone-⊆ bound — exactly as run_policies is.

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

Console-managed role mappings (migration 0051): the store half of internal/auth/oidc's RoleMappingSource. Kept out of store.go on purpose, mirroring store_capabilities.go's split.

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

User drives and their subject grants (migration 0054). Kept out of store.go on purpose, mirroring the governance.go split.

This file is DUMB on purpose: it round-trips rows and owns exactly one piece of logic, ResolveUserDrive's ORDER BY, because that precedence has to be a property of the single indexed read rather than of a caller that could forget it. Every write is validated at the API boundary (types.ValidateUserDrive and types.ValidateUserDriveGrant, called from internal/api), exactly as a governance profile's ceiling is.

Index

Constants

View Source
const AuditTargetTruncatedMarker = "…[truncated]"

AuditTargetTruncatedMarker is appended to a capped target so the row says what happened to it. Without it a truncated path reads as a real one, and an operator matching `target = '/api/v1/policies/aaa…'` would believe it.

View Source
const MaxAuditTargetLen = 512

MaxAuditTargetLen bounds the audit row's `target` column (audit_events.target is plain TEXT, i.e. unbounded).

The target is `r.URL.Path` on the two lanes an untrusted caller drives — auth.failed and authz.denied — and chi matches a path of any length the server accepted, which is MaxHeaderBytes (1 MiB) plus slack. The UNAUTHENTICATED twin is rate-limited to ~1 row/sec; the AUTHENTICATED authz.denied lane is not limited at all, so a member looping `PUT /policies/<1 MiB>` writes megabytes per second into a table whose Postgres trigger forbids DELETE. 512 bytes holds every real target this tree writes (a UUID route is ~50) with an order of magnitude to spare.

Variables

View Source
var AuditChainPageSize int32 = 1000

AuditChainPageSize is how many rows one page of the verify sweep reads. It bounds the sweep's memory: a page, never the table. Large enough that the per-page round trip is noise against re-hashing a thousand rows, small enough that the page itself is a few hundred kilobytes. A var only so the parity test can shrink it and actually cross page boundaries.

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 ErrDriveAllocated = fmt.Errorf(
	"%w: this user drive gained an allocation while the write was being prepared", ErrConflict)

ErrDriveAllocated is returned by UpsertUserDrive when the caller asked for the write to apply only while the drive has NO allocations (refuseIfAllocated) and the row has some.

It is the STATEMENT-LEVEL half of the API's re-home guard, and it exists because that guard is a read followed by an unconditional write: a grant created between the two was re-homed silently, exactly as it was before the guard existed. The precondition now rides the writing statement, under a FOR UPDATE on the drive row taken in the same transaction — which is what makes it race-free rather than merely narrow, because the predicate alone reads a snapshot an uncommitted grant INSERT is not in (see UpsertUserDrive). The guard's own doc named the fix — "the read and the write in ONE transaction" — and this is it, without putting a tx handle on the Store interface.

It wraps ErrConflict like the two above: every caller that only asks "is this a 409?" keeps working, and the ONE caller that writes the sentence can say what actually happened — somebody was allocated this drive while it was being edited.

View Source
var ErrDriveHomeNamespaceConflict = fmt.Errorf(
	"%w: another host_path drive on this host_root derives home directory names by a different rule", ErrConflict)

ErrDriveHomeNamespaceConflict is returned by UpsertUserDrive when a host_path drive would share a host_root with another host_path drive that derives home directory NAMES by a different rule.

It wraps ErrConflict so every caller that only asks "is this a 409?" keeps working unchanged; the wrap exists so the ONE caller that writes the sentence can tell this refusal apart from UNIQUE(name), which is a different remedy (pick another name vs. pick the same home_template, or another root).

View Source
var ErrDriveSlugConflict = fmt.Errorf(
	"%w: another user drive's name folds to the same storage-object name", ErrConflict)

ErrDriveSlugConflict is returned by UpsertUserDrive when a drive's name folds to a storage-object slug another drive already holds — "Corp NAS" against an existing "corp nas", or "Corp NAS (eng)" against "corp-nas-eng".

It is a DIFFERENT refusal from UNIQUE(name) with a different remedy, which is why it is its own sentinel: the name really is free, and an admin told "a drive named %q already exists" would go looking for a row that is not there. What is taken is types.DriveSlug(name) — the fragment every minted object name is built from (wardyn-drive-<slug>-<home>) — so the remedy is a name that differs by more than case or punctuation.

It wraps ErrConflict for the same reason ErrDriveHomeNamespaceConflict does: every caller that only asks "is this a 409?" keeps working unchanged.

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 CapAuditTarget added in v0.7.4

func CapAuditTarget(target string) string

CapAuditTarget bounds one audit target. A target already within the cap is returned BYTE-IDENTICAL: audit queries match this column exactly, so the cap must be invisible to every row that is not abusive.

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 this caller's seq allocation and head read cannot interleave with another writer's — keeping seq order and chain order identical. Since migration 0056 the trigger takes the same lock and allocates seq under it (the identity default's value is discarded), which is what binds writers this package knows nothing about; the lock here is re-entrant within the transaction and costs nothing. See db.AuditChainLockKey.

Types

type ActiveRunsAtPathReader added in v0.7.0

type ActiveRunsAtPathReader interface {
	ActiveRunsAtWorkspacePath(ctx context.Context, workspacePath string) ([]types.AgentRun, error)
}

ActiveRunsAtPathReader answers ONE question the create path asks on every run: which OTHER non-terminal runs already operate on this host workspace path.

It is a capability interface for Pager's reason, and it is separate from Pager for RunsByCreatorPager's: this one is answered by a WHERE clause rather than a window, so a store that does not implement it cannot be served by windowing the unbounded list. The api-layer call site falls back to the unbounded ListRuns + an in-Go filter, which is what it did before this existed — SAFE here, unlike the ownership-scoped list, because the answer is identical either way and the fallback is merely slower.

WHY IT EXISTS. warnWorkspaceCollision loaded EVERY run in the deployment to find the handful sharing one path — a Seq Scan plus a full sort of agent_runs, on every single run create, over a table nothing prunes and no retention policy bounds. The warning is advisory and never blocks a launch, so a deployment's whole run history was being sorted to produce a sentence that is usually not printed.

type ActiveRunsByCreatorReader added in v0.7.5

type ActiveRunsByCreatorReader interface {
	ActiveRunsByCreator(ctx context.Context, createdBy, task, agent string) ([]types.AgentRun, error)
}

ActiveRunsByCreatorReader answers the ONE question a new sign-in asks before it launches: which of THIS person's runs of this lane are still live, so the supersede can end them (supersedeCallerLoginRuns, internal/api).

A capability interface for ActiveRunsAtPathReader's reasons, and answered the same way — a WHERE clause, not a window — but its ABSENCE is handled differently from either neighbour: the api-layer call site (liveLoginRunsBy) takes NO fallback at all. It does not widen anything the way an unscoped list would (RunsByCreatorPager's fail-closed case), and it does not fall back to the unbounded ListRuns the way ActiveRunsAtPathReader does, because this runs on a route every store-less embedding drives and the scan would read a whole run table to find at most one row. A store without this method simply does not supersede; PG is the production store and implements it, and the capture upload's own KILLED guard is the belt.

task and agent, not "provider": a run row carries no provider column (the provider lives in the harness.login.started audit datum), and task+agent is what actually separates one lane's login box from another's.

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 ApprovalsByRunPager added in v0.7.0

type ApprovalsByRunPager interface {
	ListApprovalsPageByRun(ctx context.Context, runID uuid.UUID, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)
}

ApprovalsByRunPager is Pager.ListApprovalsPage narrowed to ONE run — the ?run_id= shape of GET /api/v1/approvals, which the CLI and the console's run detail page poll. A capability interface for Pager's reason.

WHY IT EXISTS. handleListApprovals installed the DB-paged reader only when run_id was ABSENT, so a run-scoped request fell to the fetch-all branch: Approvals.List -> store.ListApprovals -> ListApprovalsPage(ctx, state, Page{}) -> a Page with Limit<=0, which emits NO LIMIT clause at all. One run-scoped poll therefore materialised EVERY approval row the deployment had ever written, in Go, and discarded all but one run's. Decided rows are never deleted, so that read grew with deployment age — exactly the cost ListApprovalsPage was added to remove for the unfiltered list, left in place for the filtered one.

Same fail-safe contract as Pager (NOT ApprovalsByRunCreatorPager's fail-CLOSED one): an absent implementation falls back to the fetch-all + in-Go filter, which returns the identical rows and is merely slower. Ownership scoping is decided BEFORE this is reached (getRunAuthorized), so the fallback is not a privilege question.

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 AND sit BELOW the first
	// chained row — the pre-migration-0047 prefix. They are outside the chain
	// by design and are never a failure; a non-zero value on an upgraded
	// deployment is expected. A hashless row ABOVE that prefix is NOT legacy
	// and is not counted here: it is a break (see auditChainWalk.step).
	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

	// Now is THE APP CLOCK, and it exists so a test can run one that disagrees
	// with the database's. Nil means time.Now, which is what production wires.
	//
	// Every stamp this store writes belongs on the DATABASE's clock (the column
	// it will be compared against is stamped by now()), so what the app clock is
	// used for is measuring an ELAPSED TIME — a difference of two readings of
	// this one clock, which carries no skew — that the statement then subtracts
	// from the database's own now(). See db.AppClockAgeSQL. A fast clock is
	// therefore only observable through a seam like this one, exactly as
	// pgSessionRevocations.now is in wardynd.
	Now func() time.Time
}

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) ActiveRunsAtWorkspacePath added in v0.7.0

func (s PG) ActiveRunsAtWorkspacePath(ctx context.Context, workspacePath string) ([]types.AgentRun, error)

ActiveRunsAtWorkspacePath returns the non-terminal runs bound to one host workspace path.

The state predicate is the POSITIVE list (types.NonTerminalRunStates), the same choice CountActiveRunsBy makes and for the same reason: a state added to the enum and forgotten there merely under-warns, while `NOT IN (terminal)` would treat a newly-added TERMINAL state as active and warn about a collision with a run that finished.

ponytail: no new index. workspace_path is selective and the state filter is a cheap check over the rows that match it; a composite (workspace_path, state) index is the upgrade if a deployment ever has enough runs on ONE path to notice. What this replaces was not an index problem — it was reading the whole table.

func (PG) ActiveRunsByCreator added in v0.7.5

func (s PG) ActiveRunsByCreator(ctx context.Context, createdBy, task, agent string) ([]types.AgentRun, error)

ActiveRunsByCreator returns createdBy's non-terminal runs of one task+agent.

The state predicate is the POSITIVE list (types.NonTerminalRunStates), the choice CountActiveRunsBy and ActiveRunsAtWorkspacePath both make and for the same reason: a state added to the enum and forgotten here merely misses a supersede, while `NOT IN (terminal)` would hand a newly-added TERMINAL state to the kill cascade.

ponytail: no new index. agent_runs_created_by_idx already indexes the selective column and one person owns few runs; a composite is the upgrade if a deployment ever has a member with enough history to notice.

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) CountActiveRunsBy added in v0.7.0

func (s PG) CountActiveRunsBy(ctx context.Context, createdBy string) (int, error)

CountActiveRunsBy counts createdBy's runs that have not ended yet — the governance quota's one read (GovernanceLimits.MaxConcurrentRuns).

The state predicate is a POSITIVE list (types.NonTerminalRunStates), not a `NOT IN (terminal)`: a state added to the enum and forgotten there merely undercounts, while the negated form would count a new TERMINAL state as active and wedge a capped member at their limit with nothing to stop. That list is pinned against RunState.IsTerminal by a test in internal/types.

ponytail: no new index. agent_runs_created_by_idx (0001_init.sql:23) already indexes the selective column; the state filter is a cheap check over the few rows one member owns. A composite (created_by, state) index is the upgrade if a deployment ever has a member with enough historical runs to notice.

func (PG) CountApprovalsForRun added in v0.7.2

func (s PG) CountApprovalsForRun(ctx context.Context, runID uuid.UUID) (int, error)

CountApprovalsForRun returns how many approvals a run has raised, in ANY state. It is the per-run DoS bound behind handleInternalRequestApproval (maxApprovalsPerRun): a sandbox picks the hosts it asks about, so without a count it can raise rows without limit, and the only reason it had none was that api.ApprovalService exposed no way to ask. Counted in the DATABASE — the alternative (List + filter in Go) reads every approval row in the deployment on every raise, which is the cost this bound exists to avoid.

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.

groups_truncated (migration 0052) rides in as a *bool and binds as SQL NULL when nil — the same nil-is-its-own-state discipline marshalGroups keeps for the snapshot itself. NULL means "minted before anything recorded this", and its consumers read that as TRUNCATED, not as false. A plain bool here (or a DEFAULT FALSE on the column) would assert "complete" for every legacy row: fail OPEN, the exact thing the marker exists to prevent.

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) DeleteGovernanceAssignment added in v0.7.0

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

DeleteGovernanceAssignment removes one assignment by id, ErrNotFound when no row matched. Unassigning is the SUPPORTED way to widen someone back to the deployment ceiling — the deliberate act the RESTRICT on the profile delete exists to force.

func (PG) DeleteGovernanceProfile added in v0.7.0

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

DeleteGovernanceProfile removes one profile by id. ErrNotFound when no row matched (admin-only surface — no principal to scope to, no existence oracle to protect).

ErrConflict when the row is still ASSIGNED. governance_assignments.profile_id is ON DELETE RESTRICT, so Postgres refuses with a foreign-key violation (23503) rather than cascading the assignments away — and that refusal is the point: cascading would silently widen every member of the deleted profile back to the deployment ceiling with nothing said. Translating the driver error into a sentinel here is what lets the route answer a caller-fixable 409 ("unassign it first") instead of a 500.

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) DeleteRoleMapping added in v0.7.0

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

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

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) DeleteUserDrive added in v0.7.0

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

DeleteUserDrive removes one drive by id. ErrNotFound when no row matched (admin-only surface — no principal to scope to, no existence oracle to protect).

ErrConflict when the drive is still ALLOCATED. user_drive_grants.drive_id is ON DELETE RESTRICT, so Postgres refuses with a foreign-key violation (23503) rather than cascading the grants away — and that refusal is the point: the directories those grants named would still hold somebody's work, now unreachable and unaudited. Translating the driver error into a sentinel here is what lets the route answer a caller-fixable 409 instead of a 500.

func (PG) DeleteUserDriveGrant added in v0.7.0

func (s PG) DeleteUserDriveGrant(ctx context.Context, id uuid.UUID) (types.UserDriveGrant, error)

DeleteUserDriveGrant removes one grant by id AND RETURNS IT, ErrNotFound when no row matched. De-allocating is the SUPPORTED way to take a drive away — the deliberate act the RESTRICT on the drive delete exists to force — and it removes only the BINDING: nothing in the control plane deletes the volume, the claim or the directory (reclaim is a declared intent executed by a documented operator command).

RETURNING rather than a bare Exec, because the ONE caller has to describe what it just removed in an audit row and the row is gone by then. The alternative it replaced was a full ListUserDriveGrants scan taken beforehand: every allocation in the deployment loaded to describe one, and a second read that could disagree with the row the DELETE actually took. The same shape UpsertUserDriveGrant above already uses, over the same shared column list.

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. NOT on the Store interface: no handler reads one image by id (the console lists them), so requiring it of every implementation bought nothing. Kept as a PG method because store_hydrate_pg_test.go round-trips through it.

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 is hydrateAll's own batched read, not a Store-interface method: its only caller is inside this package.

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) GetGovernanceProfile added in v0.7.0

func (s PG) GetGovernanceProfile(ctx context.Context, id uuid.UUID) (types.GovernanceProfile, error)

GetGovernanceProfile returns one profile by id, or ErrNotFound.

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) GetUserDrive added in v0.7.0

func (s PG) GetUserDrive(ctx context.Context, id uuid.UUID) (types.UserDrive, error)

GetUserDrive returns one drive by id, or ErrNotFound.

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) HasGroupTierAssignments added in v0.7.0

func (s PG) HasGroupTierAssignments(ctx context.Context) (bool, error)

HasGroupTierAssignments reports whether ANY group-tier assignment exists.

It is the gate on the stale/truncated-snapshot refusal, and it is a separate, deliberately cheap read because that refusal must fire on exactly one deployment shape. A caller whose group snapshot is missing or truncated cannot have its group assignments evaluated — but on a deployment with NO group-tier rows there is nothing an unknown group could have matched, so refusing there would break "no assignment ⇒ byte-for-byte today" for every pre-upgrade session and every deployment that never adopted group profiles. EXISTS, not a count: the answer is a boolean and Postgres stops at the first row.

func (PG) HasGroupTierDriveGrants added in v0.7.0

func (s PG) HasGroupTierDriveGrants(ctx context.Context) (bool, error)

HasGroupTierDriveGrants reports whether ANY group-tier grant exists.

It is the gate on the stale/truncated-snapshot refusal, and it is a separate, deliberately cheap read because that refusal must fire on exactly one deployment shape. A caller whose group snapshot is missing or truncated cannot have their group grants evaluated — but on a deployment with NO group-tier rows there is nothing an unknown group could have matched, so refusing there would break "no grant ⇒ no drive, exactly as before" for every pre-upgrade session and every deployment that allocates by user only. EXISTS, not a count: the answer is a boolean and Postgres stops at the first row.

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

MOST RECENT BY `time`, PICKED OUT OF A seq-ORDERED WINDOW, and both halves are deliberate:

  • `seq DESC` is INSERTION order, and the audit spool replays at-least-once while keeping each event's original ev.Time. A beat spooled through a database blip therefore returns with the HIGHEST seq and an hours-old time, and /healthz — unauthenticated — publishes the eBPF sensor as degraded with a stale last_heartbeat while beats arrive normally (B8-F5).

  • The window stays on `ORDER BY seq DESC`, which is the purpose-built (action, seq DESC) index from 0017, on a hot anonymous path. A plain `ORDER BY time DESC` would abandon it and sort the whole action's history on every probe.

    TWENTY IS A WINDOW, NOT A PROOF, and the difference is worth stating: it covers a replay burst of up to twenty rows and costs nineteen extra index-scan rows per probe. A LONGER backlog — the drain replays in batches and loops until the spool clears, so an outage of more than a few heartbeat intervals produces one — pushes the fresh row out of the window and this answers the stale beat again, until the NEXT live beat arrives and takes the top of the window back. That residual is bounded by one heartbeat interval against /healthz's own TTL, and it is exactly the "transient, self-heals at the next beat" the verification accepted for B8-F5; it is pinned as documented behaviour in TestPG_LatestAuditEventByActionAnswersByEventTimeNotInsertionOrder.

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) ListApprovalsPageByRun added in v0.7.0

func (s PG) ListApprovalsPageByRun(ctx context.Context, runID uuid.UUID, stateFilter types.ApprovalState, p Page) ([]types.ApprovalRequest, error)

ListApprovalsPageByRun is ListApprovalsPage narrowed to one run, same state filter and ordering.

ponytail: no new index. 0001's approvals_run_idx (run_id) already makes this an index scan, and a single run's approvals are few enough that sorting them by requested_at is free — the cost this removes was never a missing index, it was reading the whole table. A composite (run_id, requested_at DESC) is the upgrade if one run ever accumulates enough approvals to notice.

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) ListGovernanceAssignments added in v0.7.0

func (s PG) ListGovernanceAssignments(ctx context.Context) ([]types.GovernanceAssignment, error)

ListGovernanceAssignments returns every assignment in the order the resolver itself would rank them (tier, then priority, then subject) so the console's "who gets what" table reads top-down as the precedence rule, not as insertion order that an admin then has to re-sort in their head.

func (PG) ListGovernanceProfiles added in v0.7.0

func (s PG) ListGovernanceProfiles(ctx context.Context) ([]types.GovernanceProfile, error)

ListGovernanceProfiles returns every profile by name — the console's whole table in one read, and name is the handle an admin actually looks for, so creation order (what the other List* calls sort by) would be the wrong axis here. This is deployment-wide config; there is no per-caller scoping to filter on.

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) ListGroupDenyGrants added in v0.7.0

func (s PG) ListGroupDenyGrants(ctx context.Context, capability string) ([]types.CapabilityGrant, error)

ListGroupDenyGrants returns the GROUP-subject DENY rows of one capability kind — the only rows internal/api's unresolvable-group-deny check can match, and never more than a handful even on a deployment with tens of thousands of grants.

It exists because that check runs on the FAIL-CLOSED path taken by every caller whose group snapshot is unanswerable — which includes every API token minted before 0.7, on every request — and it used to ask ListCapabilityGrants, i.e. the whole table, once per value checked. That made a holder of one pre-0.7 token able to force an unbounded full-table read per checked value: 68 ms at 20k grants against 0.35 ms for the indexed sibling, multiplied by however many values the handler examines. An authorization question whose cost scales with the size of the table it reads is an availability surface, not just a slow page.

The predicate is EXACTLY the one internal/api applied in Go afterwards (subject_type='group', effect='deny', capability=$1), moved into SQL so the rows never leave Postgres; the value-overlap matching stays in Go, where the one host/wildcard matcher already lives and where it now runs over a few rows instead of all of them. Same rows in, same answer out — see TestCapUnresolvableGroupDenyMatchesFullScan, which pins the two against each other.

The (subject_type, subject) index serves the leading equality, so this reads the group rows rather than the table. No new index: adding one is a migration, and the selectivity that matters here (subject_type) is already the index's first column.

ponytail: no EXISTS fast path in front of it. It would be a second round trip to save a query that already returns nothing on the deployments the fast path would help.

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) ListRoleMappings added in v0.7.0

func (s PG) ListRoleMappings(ctx context.Context) ([]types.RoleMapping, error)

ListRoleMappings returns every row, oldest first — both the console's People screen and internal/auth/oidc's login-time merge (mergeRoleMaps) read the whole table in one call; there is no per-caller scoping to filter on, this is deployment-wide config.

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) ListUserDriveGrants added in v0.7.0

func (s PG) ListUserDriveGrants(ctx context.Context) ([]types.UserDriveGrant, error)

ListUserDriveGrants returns every grant in the order the resolver itself would rank them (tier, then priority, then subject) so the console's "who gets what" table reads top-down as the precedence rule, not as insertion order an admin then has to re-sort in their head.

func (PG) ListUserDriveGrantsPage added in v0.7.0

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

ListUserDriveGrantsPage is ListUserDriveGrants bounded to one window — the seventh entry in Pager, and the one user_drive_grants was missing.

THE LIMIT IS NOT A COURTESY, IT CHANGES THE PLAN. This ORDER BY has no index to serve it, so unbounded it is a Seq Scan feeding a full sort: measured on this deployment's own PostgreSQL 17 at work_mem=4MB with 50,000 allocations, `external merge Disk: 5584kB`, 149.7 ms, every row materialised and serialised. The same query with the caller's LIMIT becomes a top-N heapsort bounded by the window — `Memory: 301kB`, 37.7 ms, no temp file — because Postgres only has to keep the best k rows rather than sort all n. The Seq Scan (8-11 ms) stays; removing THAT needs an index on the sort keys, which is a migration.

One row per SUBJECT (UNIQUE(subject_type, subject)) and two user subjects per person, so this table's row count is deployment headcount — the shape migration 0054's opening paragraph names as the target, not an edge case.

func (PG) ListUserDrives added in v0.7.0

func (s PG) ListUserDrives(ctx context.Context) ([]types.UserDriveListItem, error)

ListUserDrives returns every drive by name, each with the number of grants bound to it — the console's whole table in one read. Name is the handle an admin actually looks for, so creation order (what the other List* calls sort by) would be the wrong axis. This is deployment-wide config; there is no per-caller scoping to filter on.

The count rides along rather than being a second round trip per row because it is what makes the delete affordance honest: a drive with grants answers 409, and an admin should see that before clicking rather than after. A grouped subquery (not a JOIN with GROUP BY over the whole product) keeps the drive row single even when a drive holds many grants, and it is served by user_drive_grants_drive_id_idx.

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) RefreshAPITokenRoles added in v0.7.0

func (s PG) RefreshAPITokenRoles(ctx context.Context, principal, role string) error

RefreshAPITokenRoles re-stamps role on EVERY token principal holds, and it is the api-token twin of RefreshSSHKeyRoles: the OIDC callback's OnLogin hook fires both, so one login bounds both frozen credentials at once.

It exists because a token's role was frozen at mint with nothing anywhere able to refresh it. Only two statements ever touched this table — last_used_at and revoked_at — so demoting a human from admin left every outstanding wdn_ token of theirs authenticating AS AN ADMIN until somebody separately remembered DELETE /api/v1/tokens/{id}, and 0.7 widened that stamp to carry security_admin. The sibling credential got this bound in migration 0046; the token lane did not.

role only — NOT groups. The hook carries the freshly derived role and nothing else, and the group snapshot is a separate frozen field with its own fail-closed treatment (a NULL groups_truncated already reads as truncated). Widening the hook to re-stamp groups is a bigger change than this bound needs, and re-stamping a snapshot without also re-stamping its completeness bit would be worse than leaving it alone.

STILL BOUNDED-STALE, NOT LIVE, and the ceiling is the owner's next login — exactly what docs/SSH.md §Bounds already documents for the key lane. A human who never signs in again keeps the stamp; narrowing THAT needs the other half of 0046 (a role_checked_at column plus a TTL the auth path enforces), which is a migration this does not take.

No error when the principal holds no tokens: an UPDATE matching zero rows is the ordinary case for most humans, not a failure.

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) ResolveGovernanceProfile added in v0.7.0

func (s PG) ResolveGovernanceProfile(ctx context.Context, userSubjects, groups []string) (*types.GovernanceProfile, types.CapabilitySubjectType, error)

ResolveGovernanceProfile returns THE ONE profile that applies to a caller, or ErrNotFound when no assignment matches (which the caller reads as "fall through to the deployment ceiling", the absent-row back-compat rule).

The whole precedence rule is the ORDER BY, and that is deliberate: it is one indexed read on the UNIQUE(subject_type, subject) btree, so there is no second implementation in Go for a caller to skip, mis-order, or forget. Ranked, in order:

  1. TIER — user > group > all. An assignment is one admin explicitly naming one principal, so the more specific naming wins outright; no priority in the group tier can beat a user-tier row.

  2. WITHIN THE USER TIER, a sub-keyed match beats an email-keyed one. capabilitySubjects returns up to TWO user subjects (lowercased sub, then email) and an admin may legitimately have written an assignment against either, so dueling rows on the two are reachable and LIMIT 1 must not pick arbitrarily. Sub wins because it is the stable identifier — an email is reassignable, and inheriting a departed colleague's ceiling by taking their address is not a thing this should permit. Encoded as the MATCH POSITION in the caller's own userSubjects slice (array_position), so the caller's documented ordering IS the precedence and this query needs no opinion about which identity kind sits at which index.

  3. priority DESC — the admin's explicit tie-break, and the group tier's working lever (a member is usually in several groups at once).

  4. profiles.name ASC — applied in EVERY tier. Without it two same-priority rows make LIMIT 1 depend on the plan, and "why did Bob get profile B today" has no answer.

  5. assignments.subject ASC — the deterministic total-order FLOOR, and the same last key ListGovernanceAssignments already ends on (:182), so the two orderings in this file now agree.

    It changes no answer today, and the reason is worth writing down because it is a DEPENDENCY rather than a coincidence: the tier is the first key, so two rows still tied after (4) necessarily share a tier AND a profile, and this SELECT returns profile columns plus a.subject_type and nothing else per assignment — so LIMIT 1 picking either row yields byte-identical output. That held only while the projection carried no per-assignment column. The moment anyone adds a.subject, a.priority or a new assignment field to the SELECT (an audit line naming WHICH assignment matched is the obvious next ask), the tie becomes observable and the answer starts depending on the plan. One key removes the dependency instead of documenting it, so nothing has to notice when that day comes.

users/groups are normalized from nil to empty for the same reason ListCapabilityGrantsFor normalizes them: a nil Go slice binds as SQL NULL and `x = ANY(NULL)` is NULL rather than false. It fails closed either way, but a predicate whose behavior depends on a driver detail is not one to leave standing at an authorization boundary.

func (PG) ResolveReauthApproval added in v0.7.6

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

ResolveReauthApproval moves a credential_reauth approval to APPROVED and writes its credential.reauth.resolved audit row in ONE transaction.

WHY A TRANSACTION, when DecideApproval + Record would have compiled. This is the mint's own argument (internal/broker's b.mint, whose credential.mint row commits with the minted_jti burn): the state change is what unblocks a HELD credential request, and the audit row is the only durable record that a credential-bearing retry had a human act behind it. Committed separately, a crash between them leaves a run resuming on a refreshed credential with nothing in the trail saying who resolved it — invariant I7's exact failure. So the row moves only if its record lands: if the audit write fails or the commit does, the approval stays PENDING and the idempotent reconcile-on-read (internal/api's reconcileReauthOnRead) tries again on the next poll.

It is NOT part of the Store interface, for the reason iface.go states about transactional surfaces and store.Pager's seam documents about test doubles: internal/api type-asserts for it, and a store without it simply does not resolve eagerly.

The CAS is DecideApproval's own: WHERE state='PENDING', so a concurrent resolver (two captures, or a capture racing the reconcile-on-read) loses cleanly with ErrAlreadyDecided and writes no second audit row.

func (PG) ResolveUserDrive added in v0.7.0

func (s PG) ResolveUserDrive(ctx context.Context, userSubjects, groups []string) (
	*types.UserDrive, *types.UserDriveGrant, types.CapabilitySubjectType, error)

ResolveUserDrive returns THE ONE drive that applies to a caller, the grant that won, and the tier it won at — or ErrNotFound when no grant matches, which the caller reads as "this principal has no drive" (the absent-row doctrine).

The whole precedence rule is the ORDER BY, copied from ResolveGovernanceProfile because it is the same rule about the same subject vocabulary, and it is one indexed read on the UNIQUE(subject_type, subject) btree so there is no second implementation in Go for a caller to skip, mis-order, or forget. Ranked, in order:

  1. TIER — user > group > all. A grant is one admin explicitly naming one principal, so the more specific naming wins outright; no priority in the group tier can beat a user-tier row.
  2. WITHIN THE USER TIER, a sub-keyed match beats an email-keyed one. capabilitySubjects returns up to TWO user subjects (lowercased sub, then email) and an admin may legitimately have written a grant against either. Sub wins because it is the stable identifier — an email is reassignable, and inheriting a departed colleague's DRIVE by taking their address is emphatically not a thing this may permit. Encoded as the MATCH POSITION in the caller's own userSubjects slice (array_position), so the caller's documented ordering IS the precedence.
  3. priority DESC — the admin's explicit tie-break, and the group tier's working lever (a member is usually in several groups at once).
  4. drives.name ASC — applied in EVERY tier, and a total order across DISTINCT drives because drives.name is UNIQUE. It is what decides which of two drives a member reaches when nothing above it separates them.
  5. grants.subject ASC — THE FLOOR, and the drive's name is not one. Two grants can name the SAME drive: UNIQUE(subject_type, subject) is per SUBJECT, so two groups one member belongs to may each be granted one drive, and priority DEFAULTS to 0 on both — every key above ties, and LIMIT 1 falls to physical row order. That is not academic here, because THIS RESOLVER RETURNS THE GRANT and the grant is what carries writable_override, size_mib_override, home_override and enabled: the losing coin-flip is an admin's explicit read-only narrowing silently not applying, or a paused allocation mounting. Subject is a total order within a tier (the UNIQUE key makes subjects distinct there) and it is EXPLAINABLE, which a row id would not be — "the alphabetically first group's allocation wins" is an answer to "why did Bob get that one".

THE RULE WAS COPIED FROM ResolveGovernanceProfile, WHICH DOES NOT NEED THE LAST KEY. governance_assignments carries no per-assignment override — only profile_id and priority — so two assignments naming one profile are interchangeable and the tie is unobservable. Copying the ORDER BY into a table whose rows DO carry per-row overrides is what turned a benign gap into a decision. ListUserDriveGrants already ended with `subject`, so the console listing and the resolver now agree on the last key rather than only the first.

DISABLED GRANTS ARE IN THE QUERY, and the winner's own `enabled` decides PAUSED vs MOUNTED — a disabled row that wins its tier yields paused, never the wider row beneath it (DESIGN §2.2: turning Bob's row off cannot silently hand him the group's writable drive). Excluding them in the WHERE instead reads tidier and is the widening this must not do: the pause would fall through to whatever `all`-tier row exists, which is a drive no admin decided this member should have — and it would do so silently, because the member's only signal is a mount that appeared rather than an allocation that stopped. So there is ONE read, its ORDER BY is the whole precedence rule, and the caller reads g.Enabled to tell the two answers apart.

The tier comes back as the winning grant's own subject_type rather than a separately selected column — this resolver returns the grant itself, so unlike the governance one it has the answer in hand and cannot disagree with it.

userSubjects/groups are normalized from nil to empty for the same reason ResolveGovernanceProfile normalizes them: a nil Go slice binds as SQL NULL and `x = ANY(NULL)` is NULL rather than false. It fails closed either way, but a predicate whose behavior depends on a driver detail is not one to leave standing at an authorization boundary.

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) SetRunStatusDetail added in v0.7.6

func (s PG) SetRunStatusDetail(ctx context.Context, id uuid.UUID, detail string) error

SetRunStatusDetail scoped-writes ONLY the status_detail column — what the substrate says a STARTING run is waiting on, in its own words (migration 0063). Fed by runner.SandboxSpec.OnWaiting from inside CreateSandbox, once per CHANGE of reason.

IT DOES NOT BUMP updated_at, and that is load-bearing rather than an oversight. agent_runs.updated_at is the clock the idle reaper measures idleness by AND the clock the killed-run tail-upload grace is measured from (see TouchRun, which exists to bump it, and api/internal_live_run.go). A diagnostic line the kubelet triggers must never buy a run more life or hold a terminal run's upload door open — so this write is invisible to both. Best-effort at the call site; ErrNotFound when no row matched.

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

egress_edited_at is what makes this the documented UNDO of an `always` decision rather than a change the next restart reverses: it records that the operator restated the list at this moment, and the boot heal (ReconcileWorkspaceEgressDecisions) skips any decision older than it. It is bumped even when domains is byte-identical to what is stored — "the operator looked at this list and confirmed it" is the fact being recorded, and a diff-conditional bump would make the heal depend on whether an idempotent save changed anything.

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, and it stamps egress_edited_at for the same reason (see that method): removing a permanent DENY is an operator override the boot heal must not undo at the next restart.

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) SpendApprovalOnce added in v0.7.10

func (s PG) SpendApprovalOnce(ctx context.Context, id uuid.UUID, jti string) (bool, error)

SpendApprovalOnce spends a `once` Azure DevOps capability approval: it writes jti into the row's minted_jti if, and only if, nothing has spent it yet. spent=false means another request already did (or the row is not an approved, once-scoped, control-plane-raised escalation) and the caller must not forward.

The empty-minted_jti condition is the whole exactly-once argument: two concurrent re-resolves both reach this UPDATE, Postgres serialises them on the row, and the second finds the column already written. It is the credential mint's own write-back column, so "approval X let request Z through" is the same join "approval X minted credential Z" already is.

Not part of the Store interface, for ResolveReauthApproval's reason: the api package type-asserts for it, and a store without it spends nothing.

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.

A TERMINAL run is never touched (W6-S1), and the guard is HERE rather than at the four callers (the UI relay, both attach pumps, the SSH channel keepalives) because they share one reason and one bug. Each of them touches BEFORE the door that refuses a non-RUNNING run, and updated_at is also the clock the killed-run tail-upload grace is measured from (api/internal_live_run.go) — so an authenticated caller could keep a killed run's row fresh on a cadence and hold that door open indefinitely, which is precisely the bound the gate claims. One WHERE clause closes every lane, and a caller added later inherits it. A refusal is ErrNotFound, which every caller already discards: a keepalive for a run that has ended is a no-op by definition.

The predicate is the POSITIVE list (types.NonTerminalRunStates), never `NOT IN (terminal)`, for the reason spelled out on CountActiveRunsBy: a state added to the enum and forgotten there merely stops a keepalive, while the negated form would keep touching a newly-added TERMINAL state.

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, and ErrConflict when the rename collides with run_policies.name's UNIQUE constraint — the SAME mapping CreatePolicy has made since W20-S1-3, because the constraint is the same one and a rename onto a taken name is the same caller-fixable mistake as an insert under one (B1-F5). Without it the API's blanket 500 handed an admin the raw driver text.

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, stampEgressEdit bool) (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.

egress_edited_at is CARRIED, not stamped — UNLESS stampEgressEdit says this write is itself the operator action the column records. Migration 0055 rules out stamping unconditionally (a full-column write on an unrelated composition edit would suppress the boot heal for a decision nobody undid), and a pass-through is not a stamp: an edit that leaves the field alone rewrites the value it read. What the pass-through fixes is the opposite failure — omitting the column meant this, the THIRD durable writer of approved_egress, could CLEAR the allowlist while the stamp stayed where the last scoped setter left it, so ReconcileWorkspaceEgressDecisions re-widened the list on the next restart.

THE STAMP IS now(), FROM THE DATABASE, for the reason the two scoped setters stamp that way: the value's whole job is to be compared against approvals.decided_at in ReconcileWorkspaceEgressDecisions, and a value from wardynd's clock puts a skew straight into that inequality — in the fail-OPEN direction when the daemon runs behind, which re-applies a decision the operator had already undone. handleUpdateWorkspace used to stamp it in Go; it now passes the flag and the column is written here, on one clock (B8-F3).

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) UpsertGovernanceAssignment added in v0.7.0

func (s PG) UpsertGovernanceAssignment(ctx context.Context, a types.GovernanceAssignment) (types.GovernanceAssignment, error)

UpsertGovernanceAssignment binds one subject to one profile, keyed on the natural UNIQUE (subject_type, subject): re-assigning a subject REPOINTS its single row rather than leaving a second one behind. Two rows for one subject would make "which profile does Bob get" depend on the priority/name tie-breaks instead of on the admin's last write — resolvable, but not explainable, and an admin who cannot explain a ceiling cannot trust it.

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

Returns ErrNotFound when profile_id names no profile — the FK rejects it (23503), and "the profile you named does not exist" is a 404 the caller can act on, not a 500.

func (PG) UpsertGovernanceProfile added in v0.7.0

func (s PG) UpsertGovernanceProfile(ctx context.Context, p types.GovernanceProfile) (types.GovernanceProfile, error)

UpsertGovernanceProfile writes one profile, keyed on its PRIMARY KEY: a caller-minted id that does not exist yet INSERTs, and one that does UPDATEs in place (name included, so a profile can be renamed while assignments still point at it — which they must be able to be, since ON DELETE RESTRICT makes delete-and-recreate impossible for an assigned profile).

That single statement serves both write routes: POST mints a fresh id and therefore always inserts, PUT passes the id from the path and therefore always updates. A PUT naming an id no row has creates it at that id, which is what PUT means and costs no existence read.

Returns ErrConflict when the UNIQUE(name) index rejects the write — a NEW profile taking a taken name, or a rename onto another row's name. The caller maps that to 409 with the name in the message, never a raw driver error (the CreatePolicy contract, W20-S1-3).

created_by and created_at are NOT touched on the update path: creation provenance stays with whoever authored the profile, even after a later edit by a different admin (the A-9 rule UpsertRoleMapping follows).

func (PG) UpsertRoleMapping added in v0.7.0

func (s PG) UpsertRoleMapping(ctx context.Context, m types.RoleMapping) (types.RoleMapping, error)

UpsertRoleMapping writes one row, keyed on the natural UNIQUE (value): re-adding an already-mapped value FLIPS its role in place rather than leaving a second row behind — the same "re-granting the same key updates it" contract UpsertCapabilityGrant follows, for the same reason (two rows for one value cannot happen; the UNIQUE index already forbids it, this just makes the natural retry path a clean update instead of a 409).

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

A-9: the conflict path updates role only, deliberately NOT created_by — creation provenance (who ADDED this mapping) stays with the original creator across a later role flip by a different admin, the same way created_at is untouched on conflict (no SET at all, so Postgres leaves it).

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) UpsertUserDrive added in v0.7.0

func (s PG) UpsertUserDrive(ctx context.Context, d types.UserDrive, refuseIfAllocated bool) (types.UserDrive, error)

UpsertUserDrive writes one drive, keyed on its PRIMARY KEY: a caller-minted id that does not exist yet INSERTs, one that does UPDATEs in place (name included, so a drive can be renamed while grants still point at it — which they must be able to be, since ON DELETE RESTRICT makes delete-and-recreate impossible for an allocated drive).

RENAMING A DRIVE MOVES A PVC's NAME, and that is a documented consequence rather than a bug this store can fix: types.DriveObjectName folds the name into a k8s claim name AND a Docker volume name, so a renamed drive's members bind an object that does not exist yet and a managed drive provisions a fresh empty one. The alternative — a second immutable slug column — buys stability for the one field an admin most needs to be able to correct, and the operator runbook for a rename is `kubectl get pvc -l wardyn.drive=<id>` (the label carries the row id, so the orphans stay findable) plus the preview endpoint, which prints the exact object name for a principal. docs/OPERATIONS.md, "User drives on Kubernetes", is that runbook.

THE HANDLER NO LONGER WRITES THAT UNCONDITIONALLY. An identity-affecting PUT on a drive that already has grants — backend, home_template, host_root or name, the four columns every allocated person's storage object is derived from — is refused 409 by driveRehomeGuard unless the request carries ?confirm=rehome. The RULE stays at the API boundary, where the request that asked for it is; what this statement carries is the guard's PRECONDITION (refuseIfAllocated), which is not a second copy of the rule but the bit the boundary decided, re-asserted where the write happens. Without it the guard was a read followed by an unconditional write, and a grant created in between was re-homed silently — see ErrDriveAllocated.

Returns ErrConflict when UNIQUE(name) rejects the write — a new drive taking a taken name, or a rename onto another row's name. The caller maps that to 409 with the name in the message, never a raw driver error (the CreatePolicy contract).

─── AND THE CROSS-ROW GUARD: TWO SHARES OVER ONE ROOT MUST AGREE ON HOW A HOME IS NAMED ─────────────────────────────────────────────────────────────

A share's object name is `<host_root>/<home>` (types.DriveObjectName) — no drive component at all, because the directory was named by whoever owns the tree. So on host_path the NAMESPACE a home name has to be unique in is the host_root, not the drive. UpsertUserDriveGrant already closes the half an admin can type: the same home_override on two same-root shares. This closes the half nobody types — the DERIVED one, which that guard's own doc names as its residual and hands to "where the decision lives", i.e. here.

THE REFUSAL IS TEMPLATE DISAGREEMENT, NOT AN EQUAL ROOT. Two host_path drives on one root stay legal, because that is a real deployment shape and the one driveHostRootNesting deliberately permits: a read-write and a read-only view of /srv/homes, or two size ceilings over it. What is refused is the pair that makes the derivation non-injective ACROSS the two rows. With equal templates it cannot be: `sub` maps each principal to its own subject, so equal homes mean the same person and the same directory, which is the correct answer. Give the two drives DIFFERENT templates and it collapses — drive A on `sub` and drive B on `email_local`, a member whose sub is "alice" and a member whose address is alice@corp.example both derive "alice", and two principals are handed /srv/homes/alice read-write with every bind-time assertion passing, because each allocation is individually legitimate. That is migration 0059's stated invariant ("one home directory belongs to one principal") failing on the backend its index cannot reach.

RESIDUAL, stated rather than implied: `email_local` folds two principals whose addresses share the part before the "@" onto one home. That fold is a property of the template itself and happens on a SINGLE drive just as readily, so it is not this guard's shape and closing it here would leave `sub` as the only authorable share template; it belongs to the template rules in types.ValidateUserDrive, which already refuses `email_local` on a managed backend for exactly that reason.

IN THE STATEMENT, not in a read before it, for the reason UpsertUserDriveGrant's guard is: one round trip rather than two, so the window between "no other root-mate names homes differently" and the write is a single statement. It is still not a constraint — a cross-row rule over one table cannot be an index — so two concurrent registrations can both pass NOT EXISTS under READ COMMITTED. The case that actually happens is one admin registering a second view of a tree, and that is closed.

od.id <> the row being written, so an UPDATE never trips over itself; and the ON CONFLICT (id) DO UPDATE is gated by the same WHERE, so an EDIT that moves a drive onto a colliding root, or changes its template into disagreement with its root-mates, is refused on the same terms as a create.

created_by and created_at are NOT touched on the update path: creation provenance stays with whoever registered the drive, even after a later edit by a different admin (the same rule UpsertGovernanceProfile follows).

func (PG) UpsertUserDriveGrant added in v0.7.0

func (s PG) UpsertUserDriveGrant(ctx context.Context, g types.UserDriveGrant, homeOverrideStated bool) (types.UserDriveGrant, error)

UpsertUserDriveGrant allocates one drive to one subject, keyed on the natural UNIQUE (subject_type, subject): re-allocating a subject REPOINTS its single row rather than leaving a second one behind. Two rows for one subject would make "which drive does Bob get" depend on the priority/name tie-breaks instead of on the admin's last write — resolvable, but not explainable, and an admin who cannot explain an allocation cannot trust it.

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 subject must not be handed an id that names no row.

Returns ErrNotFound when drive_id names no drive — the FK rejects it (23503), and "the drive you named does not exist" is a 404 the caller can act on, not a 500.

Returns ErrConflict when ANOTHER subject already holds the SAME home_override in the same OBJECT-NAME NAMESPACE. A home_override names ONE PERSON'S directory — that is the whole reason a group or all row may not carry one (ValidateUserDriveGrant: "would hand every member of that group the SAME directory — the isolation a per-user subdirectory buys") — and two user rows carrying one override is that same loss spelled with two rows instead of one. It matters most on a MANAGED backend, where the override is the only remaining way to name a minted object non-injectively (the home_template arm is refused by types.ValidateUserDrive, and a hash home folds the subject), so without this guard Wardyn itself creates one volume and binds it into two people's sandboxes, read-write wherever the allocations are writable.

─── THE NAMESPACE IS THE OBJECT NAME'S, NOT THE ROW'S ────────────────────────

Scoping this to drive_id — which is what it and migration 0059's index both did — states the rule over the row that happens to carry the name rather than over the thing the name has to be unique IN, and the two are only the same on a MANAGED backend. types.DriveObjectName is explicit about it: a managed name is `wardyn-drive-<drive-slug>-<home>`, so the drive is IN the name and drive_id is exactly right; a host_path (share) name is `<host_root>/<home>`, with no drive component at all, because the directory was named by whoever owns the tree and a slug there would name a directory that does not exist.

So on shares the drive_id scope was the wrong question. Two share drives on ONE host_root — the shape internal/api's driveHostRootNesting deliberately permits, since equal roots are a naming question and only NESTED roots are a containment one — let the same override be typed once on each, and the two principals were handed one absolute host directory: both bind it at /home/agent/drive, read-write wherever their allocation is writable, and every bind-time assertion passes because each allocation is individually legitimate. The guard now asks the question in the namespace's own terms: same drive, OR two host_path drives over the same host_root.

host_root is compared as the STORED STRING, which is the same comparison DriveObjectName makes: types.ValidateUserDrive refuses a host_root that is not already filepath.Clean'd, so equal names here are equal paths. What it does not see is two textually different roots that resolve to one tree through a symlink; that needs the filesystem, which the store has no access to, and it is driveHostRootNesting (which does resolve, on the drive-write path) that owns the resolved half. RESIDUAL, stated rather than implied: a DERIVED home — no override at all — can still collide across two same-root shares, because two drives may carry different home_templates and a share's derived name is a claim substring rather than a digest (types.ValidateUserDrive refuses `hash` on a share precisely because the directory is not Wardyn's to name). Refusing that at THIS boundary is not possible: a group- or all-tier grant covers principals whose claims are not known until they sign in. Closing it is a design decision about whether two shares may share a root at all, and it belongs where that decision lives, not here.

THE GUARD IS IN THE STATEMENT, not in a read before it, so the window between "nobody else holds this name" and the write is one statement rather than two round trips. It is still not a constraint: two concurrent inserts can both pass NOT EXISTS under READ COMMITTED. Migration 0059's partial unique index on (drive_id, home_override) is the race-free floor for the SAME-DRIVE half and keeps working unchanged; the cross-drive half has no index form, because a unique index cannot span two tables (host_root lives on user_drives) — so on shares this statement is the whole guard, and the residual is the concurrent second writer, the same one driveHostRootNesting states for its own read-then- write. Both close the case that actually happens: one admin, one directory name, typed twice.

Enabled is written VERBATIM, so a zero-value grant is a DISABLED one. That is the fail-closed half (an allocation nobody enabled mounts nothing) and it puts the "new grants are on by default" decision at the API write boundary, where the request that omitted the field can still be seen — the column's own DEFAULT true never applies, because this INSERT always supplies a value.

─── AND THE SECOND GUARD IN THE SAME STATEMENT: A SILENT RE-HOME ──────────

home_override IS an identity field. The object a member binds is derived from it (DriveObjectName over the resolved home), so clearing one re-homes that person: on a managed backend their next run mounts `wardyn-drive-corp-nas-d-9f2a1c04` instead of `wardyn-drive-corp-nas-bsmith`, and the object holding their work is left behind with nothing in Wardyn naming it. The DRIVE SLUG is in both halves because it is in the minted name — types.DriveObjectName is `wardyn-drive-<drive-slug>-<home>`, and only the <home> half moves when an override is cleared; writing the pair without the slug read as though the whole name changed, which is a different (and larger) act than the one this guard refuses. On a share the same clearing re-homes them from `<host_root>/bsmith` to `<host_root>/<derived>`. That is the SAME act driveRehomeGuard answers 409 for one surface over, on the drive row — and this write had no counterpart, because ON CONFLICT replaced every override wholesale. A POST that only meant to change a priority, or to repoint a subject at another drive, cleared it.

homeOverrideStated is the request's tri-state, decided at the API boundary where "the client said nothing" is still visible (the same thing Enabled's *bool exists for, two fields apart in the same request struct). When it is FALSE the DO UPDATE is refused on a row that pins a name — no row comes back, which the caller reads as ErrConflict and answers 409 naming the remedy: state the field. Stating it is the confirmation, so there is no ?confirm= to invent and no second spelling of the act.

IN THE STATEMENT, not in a read before it, for the reason the uniqueness guard above is: the window between "does this row pin a name" and the write is one statement rather than two round trips — which is strictly better than driveRehomeGuard, whose own doc concedes its read-then-write race.

THE ZERO VALUE IS THE SAFE ONE. A caller that forgets the argument passes false, which REFUSES the clear rather than performing it.

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.

THE SWEEP IS PAGED, and that is a correctness property before it is a cost one. The comment here used to say "rows are STREAMED, not buffered", which described the Go side (pgx iteration with an early break) and not the database side: whether an unbounded `ORDER BY seq` streams or materializes is a PLANNER decision, and review observed it planning as Seq Scan -> Sort on a real audit_events, which buffers the entire table before returning row one. Under that plan the early break below saves nothing (the sort has already finished), the memory is the whole table, and an audit log cannot be pruned — the append-only triggers see to that — so the endpoint an operator reaches for during a suspected tamper incident is the one that gets slowest and hungriest over time.

Walking it in keyset pages over the primary key removes the planner's choice instead of hoping for it: `seq > $1 ORDER BY seq LIMIT n` is an index scan with a bound, so memory is one page whatever the table's statistics say, and the early break genuinely stops the work.

PAGING DOES NOT VERIFY LESS, which is the only thing that would make this a bad trade. Each page is its own snapshot, so the question is whether a row can appear BELOW the cursor after the walk has passed it. It cannot: since 0056 the trigger allocates seq while holding the chain lock and releases it at commit, so seq order IS commit order — a row is never assigned a lower seq than one that has already committed. For a writer that bypasses the trigger entirely (session_replication_role = replica, the bypass rule 3 exists to catch after the fact) the comparison is with what the single-statement version did, and it is never worse: a row committing DURING the sweep was invisible to the one snapshot the old query took, while the paged walk sees it whenever its seq is still ahead of the cursor. The walk state — prev, Checked, FirstSeq — is carried across pages by one auditChainWalk, so a chain longer than a page is one continuous chain and not a sequence of independent ones.

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)
	// ListUserDriveGrantsPage serves the drives console's allocation table. It
	// is here for the reason the six above are: one row per SUBJECT means this
	// table's size IS the deployment's headcount, and its ORDER BY has no index,
	// so unbounded it sorts every allocation on every load of one admin screen.
	ListUserDriveGrantsPage(ctx context.Context, p Page) ([]types.UserDriveGrant, 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)
	// CountActiveRunsBy counts one creator's non-terminal runs — the
	// governance quota's read (GovernanceLimits.MaxConcurrentRuns). Called
	// ONLY when an assigned profile actually sets a cap, so a deployment with
	// no governance assignments never reaches it.
	CountActiveRunsBy(ctx context.Context, createdBy string) (int, 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 writes the full column set, CARRYING ws.EgressEditedAt
	// rather than stamping it: this is the third durable writer of
	// approved_egress (handleUpdateWorkspace clears the list when the
	// composition changes) and an implementation that drops the column lets
	// ReconcileWorkspaceEgressDecisions re-widen a list the operator just
	// cleared. Callers round-trip a fetched row, so leaving the field alone
	// rewrites what was read.
	//
	// stampEgressEdit is how the ONE caller that clears the list says so: the
	// stamp is then written by the DATABASE, like every other writer of that
	// column, rather than from the caller's own clock. It has to be a flag and
	// not a value, because carrying and stamping are different acts — the
	// carried value is an EARLIER database stamp that must survive verbatim.
	UpdateWorkspace(ctx context.Context, id uuid.UUID, ws types.Workspace, stampEgressEdit bool) (types.Workspace, error)
	// SetWorkspaceApprovedEgress replaces the operator-owned approved-egress
	// list and stamps Workspace.EgressEditedAt: this PUT is the documented undo
	// for an `always` decision, and that stamp is what stops
	// ReconcileWorkspaceEgressDecisions putting a removed host back at the next
	// boot. An implementation that skips it re-opens that resurrection.
	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. It does NOT stamp EgressEditedAt — a decision is not an operator
	// override of itself, and stamping here would make the boot heal suppress
	// its own future re-applies. 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, and stamp
	// EgressEditedAt for the same reason.
	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)
	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)
	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
	// RefreshAPITokenRoles re-stamps role on every unrevoked api_tokens row a
	// principal holds. Fired from the SAME OnLogin hook as RefreshSSHKeyRoles,
	// because both credentials freeze a role at issue time and neither had any
	// way to learn about a demotion. See the implementation for why it does not
	// touch groups, and for the ceiling it does NOT remove.
	RefreshAPITokenRoles(ctx context.Context, principal, role string) 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)
	// ListGroupDenyGrants returns the group-subject DENY rows of one kind — the
	// only rows the unresolvable-group-deny refusal can match. Separate from
	// ListCapabilityGrants because that refusal is on the request path of every
	// caller with an unanswerable group snapshot (every pre-0.7 API token), and
	// answering it with the whole table made an authorization check cost scale
	// with the table's size. See the implementation.
	ListGroupDenyGrants(ctx context.Context, capability string) ([]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)

	// Console-managed role mappings (migration 0051, store_role_mappings.go):
	// the store half of internal/auth/oidc's RoleMappingSource, read once per
	// login (via the cmd/wardynd adapter that bridges store -> oidc, mirroring
	// SessionRevocations) and merged with the chart's WARDYN_OIDC_ROLE_MAP. ARE
	// part of Store for the identical reason the capability methods above are:
	// a store that cannot answer this runs on the OIDC login path, and a
	// type-assert-and-degrade seam there would mean "this fake does not
	// implement role mappings, therefore fall back to env-only", which is a
	// silent WIDENING of who derives admin under WARDYN_OIDC_DEFAULT_ROLE=admin
	// — not something a nil interface should be able to decide by omission.
	//
	// UpsertRoleMapping keys on the natural UNIQUE (value): re-adding an
	// already-mapped value flips its role in place, returning the EXISTING
	// row's id on a conflict (never the candidate's), the same contract
	// UpsertCapabilityGrant follows.
	UpsertRoleMapping(ctx context.Context, m types.RoleMapping) (types.RoleMapping, error)
	DeleteRoleMapping(ctx context.Context, id uuid.UUID) error
	// ListRoleMappings returns every row, oldest first — the console's People
	// screen and the OIDC login-time merge's whole data need.
	ListRoleMappings(ctx context.Context) ([]types.RoleMapping, error)

	// Governance profiles and their subject assignments (migration 0052,
	// governance.go): the assignable ceiling that replaces Config.DefaultPolicy
	// for a principal an admin has named. ARE part of Store, for the third time
	// and the strongest instance of the same reason: ResolveGovernanceProfile
	// runs on the REQUEST PATH of run creation, and a type-assert-and-degrade
	// seam there would mean "this store does not implement governance,
	// therefore use the DEPLOYMENT-WIDE ceiling" — which is a silent WIDENING
	// back to exactly the floor an admin declared too loose for this principal.
	// That is not a decision a nil interface gets to make by omission, so a
	// store that cannot answer it is a COMPILE error.
	//
	// UpsertGovernanceProfile keys on the PRIMARY KEY (a fresh id inserts, an
	// existing one updates in place, rename included) and returns ErrConflict
	// when UNIQUE(name) rejects the write. DeleteGovernanceProfile returns
	// ErrConflict when the profile is still ASSIGNED — the ON DELETE RESTRICT,
	// which exists so deleting a profile can never silently widen its members.
	UpsertGovernanceProfile(ctx context.Context, p types.GovernanceProfile) (types.GovernanceProfile, error)
	DeleteGovernanceProfile(ctx context.Context, id uuid.UUID) error
	ListGovernanceProfiles(ctx context.Context) ([]types.GovernanceProfile, error)
	// UpsertGovernanceAssignment keys on the natural UNIQUE (subject_type,
	// subject): re-assigning a subject REPOINTS its one row, returning the
	// EXISTING row's id on a conflict. ErrNotFound when profile_id names no
	// profile (the FK rejects it).
	UpsertGovernanceAssignment(ctx context.Context, a types.GovernanceAssignment) (types.GovernanceAssignment, error)
	DeleteGovernanceAssignment(ctx context.Context, id uuid.UUID) error
	ListGovernanceAssignments(ctx context.Context) ([]types.GovernanceAssignment, error)
	// ResolveGovernanceProfile returns THE ONE profile that applies to a caller
	// — user > group > all, sub over email within the user tier, then priority
	// DESC and name ASC — as a single indexed read whose ORDER BY IS the whole
	// precedence rule. ErrNotFound means "no assignment matched", which the
	// caller reads as the deployment ceiling. userSubjects must arrive in the
	// caller's own precedence order (capabilitySubjects' [sub, email]); the
	// query encodes match POSITION, not identity kind.
	//
	// It also returns WHICH TIER matched, and that second value is load-bearing
	// rather than informational: on a stale or truncated group snapshot the
	// caller must resolve with NO groups, and it then has to tell a user-tier
	// winner (serve it — an explicitly named principal is never locked out by a
	// snapshot problem) from an all-tier winner (refuse — a group row could
	// have outranked it). Those two are indistinguishable from the profile
	// alone, including when both tiers name the SAME profile id, and
	// re-deriving the tier in Go from the subjects would be a second copy of
	// the ORDER BY above — the dual-matcher drift this file refuses elsewhere.
	//
	// The group-tier existence leg stays a SEPARATE read (below) and is not
	// folded into this statement: the case that needs it most is the one where
	// this query matches NOTHING, and a zero-row result carries no EXISTS
	// column with it.
	ResolveGovernanceProfile(ctx context.Context, userSubjects, groups []string) (*types.GovernanceProfile, types.CapabilitySubjectType, error)
	// HasGroupTierAssignments gates the stale/truncated group-snapshot refusal:
	// with no group-tier row there is nothing an unknown group could have
	// matched, so a nil or truncated snapshot must fall through rather than
	// refuse (see the implementation).
	HasGroupTierAssignments(ctx context.Context) (bool, error)

	// User drives and their subject grants (migration 0054, user_drives.go):
	// the admin-registered per-user storage a member may mount into a run, and
	// the rows allocating one to a user, a group, or everyone. ARE part of
	// Store, for the same reason the governance resolver is: ResolveUserDrive
	// runs on the REQUEST PATH of run creation, and a type-assert-and-degrade
	// seam there would mean "this store does not implement drives, therefore
	// mount nothing" — which is a silent failure of a member's data to appear
	// rather than a refusal they can act on. A store that cannot answer it is a
	// COMPILE error.
	//
	// UpsertUserDrive keys on the PRIMARY KEY (a fresh id inserts, an existing
	// one updates in place, rename included) and returns ErrConflict when
	// UNIQUE(name) rejects the write. DeleteUserDrive returns ErrConflict when
	// the drive is still ALLOCATED — the ON DELETE RESTRICT, which exists so
	// deleting a drive can never orphan the directories its grants named.
	//
	// refuseIfAllocated is a PRECONDITION, not a rule: the write applies only
	// while the drive has no allocations, and answers ErrDriveAllocated when it
	// has some. The API's re-home guard sets it for an identity-affecting write it
	// permitted BECAUSE the drive looked unallocated, so the decision it made on a
	// read is re-asserted inside the writing statement — the same "thread the
	// request-boundary bit into the statement" shape UpsertUserDriveGrant's
	// homeOverrideStated has. The rule itself stays at the API boundary, where the
	// request that asked for it is.
	UpsertUserDrive(ctx context.Context, d types.UserDrive, refuseIfAllocated bool) (types.UserDrive, error)
	GetUserDrive(ctx context.Context, id uuid.UUID) (types.UserDrive, error)
	DeleteUserDrive(ctx context.Context, id uuid.UUID) error
	// ListUserDrives returns every drive by name WITH its grant count — the
	// count is what makes the console's delete affordance honest, since a
	// drive with grants answers 409.
	ListUserDrives(ctx context.Context) ([]types.UserDriveListItem, error)
	// UpsertUserDriveGrant keys on the natural UNIQUE (subject_type, subject):
	// re-allocating a subject REPOINTS its one row, returning the EXISTING
	// row's id on a conflict. ErrNotFound when drive_id names no drive (the FK
	// rejects it). ErrConflict when ANOTHER subject already holds this drive
	// with the same home_override — a directory name is one person's, which is
	// why a group row may not carry one, and on a managed drive it is the last
	// way left to point two people at one object Wardyn creates.
	//
	// homeOverrideStated is the REQUEST's tri-state, not the row's: false means
	// the caller never mentioned home_override, and a repoint that never
	// mentioned it must not CLEAR one — see the guard in PG's statement.
	// ErrConflict is that refusal too, and the two causes are distinguishable
	// by this argument alone (a stated override disables the re-home guard; an
	// unstated one is empty, which short-circuits the uniqueness guard).
	UpsertUserDriveGrant(ctx context.Context, g types.UserDriveGrant, homeOverrideStated bool) (types.UserDriveGrant, error)
	// DeleteUserDriveGrant RETURNS the row it removed (ErrNotFound when none
	// matched): the delete's own audit row has to name the subject that was
	// de-allocated, and by then it is gone.
	DeleteUserDriveGrant(ctx context.Context, id uuid.UUID) (types.UserDriveGrant, error)
	ListUserDriveGrants(ctx context.Context) ([]types.UserDriveGrant, error)
	// ResolveUserDrive returns THE ONE drive that applies to a caller — user >
	// group > all, sub over email within the user tier, then priority DESC and
	// the drive's name ASC — as a single indexed read whose ORDER BY IS the
	// whole precedence rule, the same one ResolveGovernanceProfile carries.
	// DISABLED grants are IN the query: one that wins its tier comes back with
	// Enabled false, which the caller renders as PAUSED rather than falling
	// through to the wider row beneath it (DESIGN §2.2 — a pause must never
	// widen a member onto a drive no admin chose for them). ErrNotFound means
	// "no grant matched at all", which the caller reads as "no drive".
	//
	// It returns the winning GRANT beside the drive because every override the
	// resolution needs (size, writable, home) is a column on the binding row —
	// and the tier it returns is that grant's own subject_type, so unlike the
	// governance resolver there is nothing here that can disagree with the row
	// it came from. The tier is still load-bearing: on a stale or truncated
	// group snapshot the caller must resolve with NO groups and then tell a
	// user-tier winner (serve it) from an all-tier one (refuse — a group row
	// could have outranked it).
	ResolveUserDrive(ctx context.Context, userSubjects, groups []string) (
		*types.UserDrive, *types.UserDriveGrant, types.CapabilitySubjectType, error)
	// HasGroupTierDriveGrants gates the stale/truncated group-snapshot refusal:
	// with no group-tier row there is nothing an unknown group could have
	// matched, so a nil or truncated snapshot must fall through rather than
	// refuse (see the implementation).
	HasGroupTierDriveGrants(ctx context.Context) (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