statedb

package
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package statedb provides a SQLite-backed persistent store for cloop project state. It replaces the JSON flat-file store with a normalized schema that allows concurrent-safe writes, real SQL queries for reporting, and historical task rows.

Package statedb — typed sentinel errors.

HTTP handlers, the orchestrator, and CLI commands need to distinguish a handful of well-known failure modes from a generic "something went wrong". The Go convention is to expose package-level sentinel errors and have callers use errors.Is to test against them. This file is the authoritative list for cloop's relational data layer.

HTTP mapping (used by pkg/ui and pkg/apiserver via HTTPStatus below):

ErrTaskNotFound    → 404 Not Found
ErrProjectNotFound → 404 Not Found
ErrStaleVersion    → 409 Conflict
ErrDBLocked        → 503 Service Unavailable
ErrSchemaMismatch  → 500 Internal Server Error

All these errors are safe targets for errors.Is. Internal helpers wrap driver errors with %w so the sentinel survives across package boundaries.

Maintenance helpers for the SQLite state database (Task 20107).

VACUUM reclaims space from rows previously deleted (cloop deletes step rows on `cloop compact`, prunes archived tasks, etc.). ANALYZE refreshes the query planner's statistics so growing tables continue to use sensible indexes. Both operations run as autocommit statements and acquire d.mu so no other goroutine in this process can interleave with them; cross-process contention is bounded by busy_timeout=5000.

Persistence: every successful VACUUM/ANALYZE run is appended to the `maintenance_log` table by AppendMaintenanceLog so:

  • cloop db maintain --auto can compare current page_count against the last vacuum's page_count_after to decide whether to skip,
  • cloop doctor can surface "last maintenance: 3d ago, freed 4.2 MB",
  • operators have a forensic trail when investigating disk-usage spikes.

Schema migration framework for statedb.

Replaces the prior "execute the entire CREATE TABLE IF NOT EXISTS blob on every Open" approach with versioned, append-only .sql files embedded into the binary. Each migration runs inside its own transaction and records itself in schema_migrations on success; a crash mid-migration rolls back cleanly so the next Open re-attempts from the failed version.

Lifecycle:

Open() ──► applyPragmas() ──► Migrate() ──► return *DB

Adding a new migration:

  1. Create pkg/statedb/migrations/NNNN_<slug>.sql where NNNN is the next unused 4-digit version (e.g. 0002_add_pinned.sql).
  2. Write idempotent-friendly DDL. Single-statement migrations are safest because the modernc.org/sqlite driver does not natively support executing multiple statements with the binding parameter API; the migration runner therefore splits on `;` boundaries before exec.
  3. NEVER edit a shipped migration file. Roll forward with another file.

Existing databases (pre-framework) are detected at boot: if all the 0001 tables already exist but schema_migrations is empty, version 1 is recorded as the baseline rather than re-applied. This makes the rollout safe for databases written by older binaries.

Package statedb — Store interface for the relational data layer. The DB struct (SQLite) implements Store. A future PostgreSQL driver need only implement this interface to be used as a drop-in replacement.

Index

Constants

View Source
const (
	// ExecutorSessionRunning: work is in flight on the executor.
	ExecutorSessionRunning = "running"
	// ExecutorSessionRequeued: a supervisor claimed the session for failover
	// and the work has been handed to another executor.
	ExecutorSessionRequeued = "requeued"
	// ExecutorSessionFinished: the work completed on this executor.
	ExecutorSessionFinished = "finished"
	// ExecutorSessionFailed: the work ended in failure and was not requeued.
	ExecutorSessionFailed = "failed"
)

Executor session states as persisted in the state column.

View Source
const (
	ExecutorStatusOnline   = "online"
	ExecutorStatusOffline  = "offline"
	ExecutorStatusDegraded = "degraded"
	ExecutorStatusUnknown  = "unknown"
)

Executor health states as persisted in the status column.

View Source
const AuditGenesisHash = genesisHash

AuditGenesisHash is the prev_hash of a chain that has never been pruned. Exported so the sealer can start its own verification from the same place the verifier does.

View Source
const EnvAllowSchemaDowngrade = "CLOOP_ALLOW_SCHEMA_DOWNGRADE"

EnvAllowSchemaDowngrade names the operator opt-out. Set it to 1/true/yes/on to open a database whose schema is ahead of this binary's.

An environment variable rather than a config key on purpose: the check runs inside statedb.Open, which is reached from CLI commands, the orchestrator and the hub alike, several of them before any config file has been read. A knob that cannot be set in every context where the guard fires is not an opt-out.

View Source
const HubScope = "hub"

HubScope is the fence name this binary writes. See the migration for why the column exists at all when there is only ever one value.

View Source
const LatestVersion = 1 << 30

LatestVersion is the sentinel MigrateTo accepts to mean "every embedded migration". Any real schema version is far below it.

View Source
const NoStep = -1

NoStep is the EventRow.Step value for events that are not bound to any step. Steps are 0-based, so 0 is a real step number — callers recording a non-step-bound event must set Step to NoStep explicitly.

Variables

View Source
var (
	// ErrTaskNotFound indicates the requested task ID does not exist in the
	// project plan. Returned by lookup helpers in pkg/state and pkg/statedb.
	ErrTaskNotFound = errors.New("statedb: task not found")

	// ErrProjectNotFound indicates the working directory has no cloop project
	// (no state.db, no migratable state.json). Returned by state.Load.
	ErrProjectNotFound = errors.New("statedb: project not found")

	// ErrStaleVersion indicates an optimistic-concurrency check failed: the
	// caller's plan version no longer matches the on-disk version. Callers
	// should re-load and re-apply.
	ErrStaleVersion = errors.New("statedb: stale plan version")

	// ErrDBLocked indicates SQLite returned a busy/locked error that the
	// busy_timeout did not absorb (extreme contention or a long-running
	// writer in another process). Callers may retry the operation.
	ErrDBLocked = errors.New("statedb: database busy")

	// ErrSchemaMismatch indicates the on-disk schema is not what this binary
	// expects (corruption, partial migration, or a future binary's database
	// opened by an older one). Manual intervention is usually required.
	ErrSchemaMismatch = errors.New("statedb: schema mismatch")

	// ErrSchemaTooNew indicates the database has been migrated past the
	// highest migration this binary embeds — an older binary opened a
	// database a newer one moved forward.
	//
	// Distinct from ErrSchemaMismatch because the remedy is different: this
	// is fixed by running a different binary, not by repairing the database.
	// Errors carrying it also satisfy errors.Is for ErrSchemaMismatch, so
	// callers that only handle the general case keep working. The detail —
	// both versions, and which build applied the newer schema — is in
	// *SchemaTooNewError, reachable with errors.As.
	ErrSchemaTooNew = errors.New("statedb: database schema is newer than this binary")

	// ErrExecutorNotFound indicates the requested execution backend is not
	// registered in the executors table. Callers must treat this as fatal
	// for the operation rather than falling back to host execution — see
	// pkg/executor's fail-closed resolution policy.
	ErrExecutorNotFound = errors.New("statedb: executor not found")

	// ErrBrokerSecretNotFound indicates no broker_secrets row has the
	// requested ID. Like ErrExecutorNotFound this is fatal for the
	// operation: a caller must never substitute a different credential for
	// one it could not find.
	ErrBrokerSecretNotFound = errors.New("statedb: broker secret not found")

	// ErrBrokerGrantNotFound indicates no broker_grants row has the
	// requested ID.
	ErrBrokerGrantNotFound = errors.New("statedb: broker grant not found")

	// ErrEgressGrantNotFound indicates no egress_grants row has the
	// requested ID. Like the broker sentinels this is fatal for the
	// operation: a caller must never fall back to a different egress policy
	// than the one it asked for.
	ErrEgressGrantNotFound = errors.New("statedb: egress grant not found")

	// ErrExecutorHealthNotFound indicates the executor has no health record:
	// the scheduler has never probed it. Callers must not read this as
	// "healthy" — an unobserved executor is unknown, not ready.
	ErrExecutorHealthNotFound = errors.New("statedb: executor health not found")

	// ErrExecutorSessionNotFound indicates no executor_sessions row has the
	// requested ID.
	ErrExecutorSessionNotFound = errors.New("statedb: executor session not found")

	// ErrExecutorSessionClaimLost indicates a conditional requeue matched no
	// row: another supervisor already claimed the session, or it is no longer
	// running. This is the exactly-once signal, and it is a distinct error
	// because "someone else is failing this over" must never be retried as if
	// it were a transient fault — doing so is how the same work ends up
	// running twice.
	ErrExecutorSessionClaimLost = errors.New("statedb: executor session claim lost")

	// ErrAPITokenNotFound indicates no api_tokens row has the requested ID.
	//
	// On the verification path this is an ordinary outcome, not a fault: it
	// is what a forged or already-purged token id looks like. Callers must
	// answer it exactly as they answer a bad secret — an unauthenticated
	// caller must not be able to use the difference to learn which token ids
	// exist.
	ErrAPITokenNotFound = errors.New("statedb: api token not found")

	// ErrSessionNotFound indicates no sessions row has the requested id.
	//
	// Like ErrAPITokenNotFound this is an ordinary outcome on the
	// authentication path, not a fault: it is what a forged cookie, a cookie
	// from before a restart that wiped the database, and a session an
	// administrator just terminated all look like. All three must produce the
	// same answer to the caller.
	ErrSessionNotFound = errors.New("statedb: session not found")
)

Sentinel errors. Add to this list — do not rename or reuse.

View Source
var (
	// ErrEnrollmentNotFound: no enrollment token with that ID.
	ErrEnrollmentNotFound = errors.New("statedb: enrollment token not found")
	// ErrAgentCredentialNotFound: no agent credential with that ID.
	ErrAgentCredentialNotFound = errors.New("statedb: agent credential not found")
	// ErrEnrollmentSpent: the token was already redeemed or revoked. This is
	// the replay signal, and it is a distinct error because "someone reused
	// this token" is an operational event worth surfacing, not a generic
	// failure.
	ErrEnrollmentSpent = errors.New("statedb: enrollment token already redeemed or revoked")
)

Sentinel errors for enrollment persistence. Callers use errors.Is.

View Source
var (
	// ErrKEKNotFound: no KEK row with that ID.
	ErrKEKNotFound = errors.New("statedb: sealing key not found")
	// ErrKEKInUse: retirement was refused because sealed rows still
	// reference the key. The refusal is computed inside the same
	// transaction as the retirement, so a row written concurrently either
	// blocks the retirement or arrives after it — never in between.
	ErrKEKInUse = errors.New("statedb: sealing key still referenced by sealed rows")
)

Sentinels for the key registry.

View Source
var ErrAuditAnchorNotFound = errors.New("statedb: no audit anchor")

ErrAuditAnchorNotFound is returned by LatestAuditAnchor when the trail has never been pruned. Callers treat it as "the chain starts at genesis", which is the pre-Task-20218 behaviour and not an error condition.

View Source
var ErrExecutorHandleNotFound = errors.New("statedb: executor handle not found")

ErrExecutorHandleNotFound is returned when no row exists for a handle id.

View Source
var ErrHubLeaseNotFound = errors.New("statedb: hub lease not found")

ErrHubLeaseNotFound is returned when no row exists for a scope.

Functions

func AllowSchemaDowngradeFromEnv

func AllowSchemaDowngradeFromEnv() bool

AllowSchemaDowngradeFromEnv reports whether the operator opt-out is set.

Exported so `cloop hub doctor` can tell an operator the guard is disabled. A safety check that can be switched off silently gets switched off and left that way, so the off state is itself a finding.

func AuditChainLink(ev AuditEvent) (want string, ok bool)

AuditChainLink recomputes one row's hash and reports whether it matches, so callers outside this package (the sealer, which must not archive a chain that is already broken) can verify without duplicating computeRowHash.

func AuditExecutorLifecycle

func AuditExecutorLifecycle(d *DB, in ExecutorAuditInput)

AuditExecutorLifecycle records an executor-fleet mutation. Best-effort, like every other emitter in this file: a wedged audit log must not stop an operator from cordoning a misbehaving node.

func AuditImagePolicyDenial

func AuditImagePolicyDenial(d *DB, in ImagePolicyDenialInput)

AuditImagePolicyDenial records a container image refused by the hub's trust policy. Best-effort, like every other emitter here.

func AuditSecretDecision

func AuditSecretDecision(d *DB, in SecretAuditInput)

AuditSecretDecision records a secret-broker mint, grant, lease, renew, revoke, or denial. Best-effort, like every other emitter in this file: an unavailable audit log must not stop an executor from receiving the credentials it was granted.

func AuditTaskDelete

func AuditTaskDelete(d *DB, taskID int, actor string)

AuditTaskDelete records a task removal. EntityID is the deleted task's id.

func AuditTaskStatus

func AuditTaskStatus(d *DB, taskID int, oldStatus, newStatus, actor string)

AuditTaskStatus records a manual status flip (UI/CLI initiated).

func AuditTaskUpsert

func AuditTaskUpsert(d *DB, t *pm.Task, actor string)

AuditTaskUpsert is the exported wrapper that callers outside this package (orchestrator, UI handlers) use to emit a task-mutation event with their own actor identity. Best-effort.

func AuditWorkspaceProvision

func AuditWorkspaceProvision(d *DB, in WorkspaceAuditInput)

AuditWorkspaceProvision records one workspace-provisioning phase.

Best-effort, like every other emitter in this file: an unavailable audit log must not stop a workload from getting its source tree.

func HTTPStatus

func HTTPStatus(err error) int

HTTPStatus returns the HTTP status code that best represents err for an API response. Unknown errors map to 500. Use this from any HTTP handler to keep status-code logic out of individual call sites.

if err != nil {
    jsonErr(w, err.Error(), statedb.HTTPStatus(err))
    return
}

func LatestSchemaVersion

func LatestSchemaVersion() (int, error)

LatestSchemaVersion returns the highest migration version this *binary* carries, which is what a database will be at once Migrate has run.

It exists so a diagnostic can compare the two numbers. On its own, CurrentSchemaVersion answers "what has been applied" and cannot distinguish a fully-migrated database from one an older binary left behind — and the second is the interesting case, because a hub rolled back to a previous image runs against a schema from the future and fails on whichever column it does not know about.

func MarshalAuditPayload

func MarshalAuditPayload(v any) string

MarshalAuditPayload is a small helper around json.Marshal used by audit callers that want a non-empty payload but don't want to import encoding/json at every call site. Returns "" on marshal failure rather than aborting the caller's flow — the audit row still records the mutation type.

Every payload is passed through redaction on the way out (see audit_redact.go), so no emitter can write credential material into the chain even by accident. Doing it here rather than at each call site is the point: the audit table is append-only and hash-chained, so a leaked secret cannot be edited out afterwards without breaking the chain.

func MarshalHandleMeta

func MarshalHandleMeta(meta map[string]string) string

MarshalHandleMeta renders a driver's extras for the meta_json column. A nil or empty map becomes "{}" rather than "", so the column always holds valid JSON and a reader never has to special-case the empty string.

func SealedSetNames

func SealedSetNames() []string

SealedSetNames returns the populations retirement checks, so a test can assert they match the sets registered for rotation.

func SetAuditEnabled

func SetAuditEnabled(on bool)

SetAuditEnabled toggles audit emission globally. Intended for tests and for callers running cloop against a database where the audit_events table is not desired (e.g. one-shot CLI commands that read state).

func SetBinaryVersion

func SetBinaryVersion(v string)

SetBinaryVersion records which build this process is, for stamping into schema_migrations when it applies a migration. Call it once during process initialisation; cmd does so with the ldflags-injected version.

It lives here rather than being read from pkg/cmd because pkg/statedb sits below the CLI in the import graph and must not depend on it.

func UnmarshalHandleMeta

func UnmarshalHandleMeta(s string) map[string]string

UnmarshalHandleMeta parses the meta_json column. Unparsable metadata is dropped rather than propagated: the extras are advisory, and refusing to rehydrate a workload because a hint field is corrupt would strand a running container to protect a label.

Types

type APITokenRow

type APITokenRow struct {
	ID           string    `json:"id"`
	Name         string    `json:"name"`
	Hash         string    `json:"-"` // never serialized: see the migration comment
	Prefix       string    `json:"prefix"`
	Roles        []string  `json:"roles"`
	ProjectScope []string  `json:"project_scope,omitempty"`
	CreatedBy    string    `json:"created_by,omitempty"`
	CreatedAt    time.Time `json:"created_at"`
	ExpiresAt    time.Time `json:"expires_at,omitempty"`
	LastUsedAt   time.Time `json:"last_used_at,omitempty"`
	RevokedAt    time.Time `json:"revoked_at,omitempty"`

	// Kind labels a hub-issued token; "" is an ordinary PAT (migration 0023).
	Kind string `json:"kind,omitempty"`

	// OwnerJSON is the owning identity's claim bundle, stored opaquely. This
	// layer deliberately does not know its shape: pkg/apitoken owns the
	// encoding, and a second opinion here about what a claim set looks like is
	// how the two drift apart. Never serialized outward for the same reason
	// Hash is not — it carries a user's email and group memberships.
	OwnerJSON string `json:"-"`
}

APITokenRow is one persisted API token.

Roles and ProjectScope are stored as JSON arrays rather than normalized child tables: both are short, always read whole with the row, and are validated by pkg/apitoken on the way in and on the way out. A join here would buy nothing and would put the verification path — which runs on every authenticated request — behind three queries instead of one.

type AgentCredentialRow

type AgentCredentialRow struct {
	AgentID string
	Name    string
	// SecretHash is hex SHA-256 of the credential secret, never the secret.
	SecretHash   string
	WorkDirRoot  string
	Labels       map[string]string
	CreatedAt    time.Time
	LastSeen     time.Time
	RevokedAt    time.Time
	EnrollmentID string
}

AgentCredentialRow is one enrolled agent's long-lived credential.

type AuditAnchor

type AuditAnchor struct {
	ID        int64
	CreatedAt time.Time
	Actor     string

	// Cutoff is the --before instant the operator asked for. Kept distinct
	// from the ids it resolved to so an audit of the audit can tell a policy
	// decision from its effect.
	Cutoff time.Time

	PrunedFirstID   int64
	PrunedThroughID int64
	PrunedCount     int64

	// BoundaryHash is the row_hash of PrunedThroughID: what the first
	// surviving row's prev_hash must equal.
	BoundaryHash string

	// RetainedFromID is the first surviving id, or 0 when the prune emptied
	// the table.
	RetainedFromID int64

	ExportPath   string
	ExportFormat string
	ExportSHA256 string
	ExportBytes  int64

	PrevAnchorHash string
	AnchorHash     string
}

AuditAnchor records one deliberate truncation of the audit chain. See migration 0027 for what each field is load-bearing for.

type AuditEvent

type AuditEvent struct {
	ID         int64     // monotonic primary key, assigned on append
	Timestamp  time.Time // UTC
	Actor      string    // "system", "orchestrator", "ui", "cli", or user-supplied
	EventType  string    // e.g. "task.create", "plan.update", "config.set"
	EntityType string    // "task" | "plan" | "config" | "step" | "session"
	EntityID   string    // stringified id ("12" for task 12), empty for singletons
	Payload    string    // JSON blob describing the mutation
	PrevHash   string    // 64-hex SHA-256 of prior row, or 64 zeros for the first row
	RowHash    string    // 64-hex SHA-256 of this row
}

AuditEvent is one row in the audit_events table.

type AuditFilter

type AuditFilter struct {
	Actor      string    // exact-match
	EntityType string    // exact-match (e.g. "task")
	EntityID   string    // exact-match within EntityType
	EventType  string    // exact-match (e.g. "task.update")
	Since      time.Time // only events with timestamp >= Since
	Until      time.Time // only events with timestamp <= Until
	FromID     int64     // only events with id >= FromID
	ToID       int64     // only events with id <= ToID
	Search     string    // case-insensitive LIKE on payload
	Order      string    // "asc" (default) or "desc"
	Limit      int       // 0 = no limit (capped to 10 000 internally)
	Offset     int
}

AuditFilter narrows ListAuditEvents down to the rows the caller cares about. All fields are optional — zero values mean "no filter".

type AuditPruneCandidate

type AuditPruneCandidate struct {
	FirstID   int64
	ThroughID int64
	Count     int64
	// BoundaryHash is the row_hash at ThroughID, carried so the seal and the
	// delete can be checked against the same value.
	BoundaryHash string
	// Remaining is how many rows would survive.
	Remaining int64
}

AuditPruneCandidate describes what a prune with the given cutoff would remove, without removing anything. Both the CLI's --dry-run and the real path use it, so the number an operator is shown is the number that runs.

type AuditVerifyReport

type AuditVerifyReport struct {
	Total     int    // rows checked
	OK        bool   // true when every link verified
	BreakAtID int64  // first row whose hash did not match (0 if OK)
	Reason    string // human-readable description of the break

	// ExpectedHash and ActualHash carry the two hashes the break compared,
	// in full rather than the abbreviated form embedded in Reason. Reason is
	// for a human reading a terminal; these are for a UI that wants to show
	// them side by side, and for a report that gets pasted into a ticket
	// where a 12-character prefix is not enough to reason about. Both are
	// empty for an id-gap break, where no hash was compared, and for a
	// clean run.
	ExpectedHash string
	ActualHash   string

	// Anchored reports that a retention prune has truncated the chain and the
	// walk was validated against an anchor rather than against genesis. The
	// remaining fields describe that anchor, so a report can say "verified
	// from id N; everything before it is in <export>, sha256 <digest>" instead
	// of leaving an operator to wonder why the trail does not start at 1.
	Anchored     bool
	AnchorID     int64
	PrunedCount  int64
	ExportPath   string
	ExportSHA256 string

	// VerifiedFromID is the id of the first row the walk checked, or 0 when
	// the table is empty.
	VerifiedFromID int64
}

AuditVerifyReport summarises a chain-verification run.

type BrokerGrantRow

type BrokerGrantRow struct {
	ID              string
	SecretID        string
	Scope           string
	SubjectType     string
	SubjectValue    string
	ConstraintsJSON string
	ExpiresAt       string
	CreatedAt       string
	CreatedBy       string
	RevokedAt       string
}

BrokerGrantRow is one row of broker_grants.

type BrokerSecretRow

type BrokerSecretRow struct {
	ID      string
	Kind    string
	Name    string
	Payload []byte // ciphertext under this row's DEK; never plaintext
	// KeyID names the KEK that WrappedDEK is sealed under, or "legacy" for
	// rows predating envelope encryption (Task 20181). WrappedDEK is the
	// row's data key, itself sealed. Neither is key material on its own.
	KeyID        string
	WrappedDEK   []byte
	MetadataJSON string
	CreatedAt    string
	CreatedBy    string
}

BrokerSecretRow is one row of broker_secrets. Times are RFC3339 strings, matching the rest of the schema.

type CostEntry

type CostEntry struct {
	Timestamp      time.Time
	TaskID         int
	TaskTitle      string
	Provider       string
	Model          string
	InputTokens    int
	OutputTokens   int
	ThinkingTokens int
	EstimatedUSD   float64
}

CostEntry records the cost of one task execution in the costs table.

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB is a thread-safe handle to the SQLite state database. Open one DB per workdir; Close it when done.

func Open

func Open(dbPath string) (*DB, error)

Open opens (or creates) the SQLite database at dbPath, applies tuning pragmas, and runs any pending schema migrations.

Concurrency model: every cloop process (Web UI, orchestrator, CLI commands) opens its own *DB handle pointing at the same .cloop/state.db file. WAL mode + busy_timeout coordinate them at the SQLite file level, so writes from one process do not return SQLITE_BUSY to readers in another.

Errors returned by this function may wrap the typed sentinels ErrDBLocked or ErrSchemaMismatch — callers should use errors.Is to distinguish them from generic open failures. A database migrated past this binary's schema is refused with ErrSchemaTooNew rather than opened; see schema_guard.go.

func OpenWithOptions

func OpenWithOptions(dbPath string, opts OpenOptions) (*DB, error)

OpenWithOptions is Open with the version-skew opt-out under the caller's control rather than the environment's.

func (*DB) AcquireHubLease

func (d *DB) AcquireHubLease(row HubLeaseRow, expect HubLeaseRow) (bool, error)

AcquireHubLease takes the lease for row.Scope, conditioned on the current row still matching expect.

It returns true when the lease was taken and false when it was not, without an error in either case: losing a race is an ordinary outcome here, not a failure, and the caller needs to re-read the row to describe the winner anyway.

expect is the row the caller previously read, and is the entire concurrency story. A zero-valued expect means "I saw no row": the INSERT then succeeds only if none appeared in the meantime, because a conflict falls through to a DO UPDATE whose WHERE cannot match. A populated expect means "I saw this row and judged it takeable": the update applies only if it is still byte-for-byte that row, so a holder that renewed in the intervening microseconds keeps its lease and this caller is told it lost.

This is one statement on purpose. SQLite applies it atomically without an explicit transaction, which avoids the read-then-upgrade pattern that deadlocks two processes against each other under WAL — the exact failure the busy_timeout cannot absorb.

func (*DB) Analyze

func (d *DB) Analyze() error

Analyze runs ANALYZE to refresh the query planner's per-index statistics. Cheap on small databases; on large ones it scales roughly with total row count across all indexes.

func (*DB) AppendAuditEvent

func (d *DB) AppendAuditEvent(ev *AuditEvent) error

AppendAuditEvent appends one row to the audit_events table, computing the hash chain link from the previous row. Holds the DB mutex so the chain is consistent under concurrent writers in the same process. Cross-process safety is provided by SQLite WAL + busy_timeout — concurrent writers serialise at the file level.

Mutates ev in place: the assigned ID, computed hashes, and any auto-populated fields (Timestamp, Actor) are written back so callers can inspect them.

func (*DB) AppendAuditEvents

func (d *DB) AppendAuditEvents(evs []*AuditEvent) error

AppendAuditEvents chains and inserts a whole batch in a single transaction.

This is the bulk path, and the reason it exists is measured: emitting a 417-task plan through AppendAuditEvent cost 417 transactions and 417 tip reads per SaveState, which is most of what made the audit trail expensive enough to notice. Chaining in-process and writing once is the same chain with one commit.

Events are chained in slice order, so callers control causal order. Each element is mutated in place with its assigned ID and hashes. A nil or empty slice is a no-op; a nil element, or one with an empty event type, is an error and nothing is written.

func (*DB) AppendCost

func (d *DB) AppendCost(entry CostEntry) error

AppendCost inserts a cost entry into the costs table.

func (*DB) AppendMaintenanceLog

func (d *DB) AppendMaintenanceLog(e MaintenanceLogEntry) (int64, error)

AppendMaintenanceLog inserts a maintenance_log row. Returns the new row id. StartedAt / CompletedAt default to time.Now().UTC() when zero so callers don't have to fill them in for ad-hoc records.

func (*DB) AppendProviderCall

func (d *DB) AppendProviderCall(row ProviderCallRow) (int64, error)

AppendProviderCall inserts one provider-call row and returns the assigned id. Best-effort: errors are wrapped through classifyDriverErr so callers can distinguish ErrDBLocked from real corruption, but production code paths typically log-and-discard.

func (*DB) AppendStep

func (d *DB) AppendStep(row StepRow) error

AppendStep inserts a step row (idempotent on step number).

func (*DB) AuditDistinctActors

func (d *DB) AuditDistinctActors() ([]string, error)

AuditDistinctActors returns the set of distinct actor values across the audit log, sorted ascending. Used by the Web UI's filter dropdown so the user sees only actors that actually exist.

func (*DB) AuditDistinctEntityTypes

func (d *DB) AuditDistinctEntityTypes() ([]string, error)

AuditDistinctEntities returns the set of distinct (entity_type, entity_id) pairs as "type/id" strings (id may be empty for singletons). Sorted.

func (*DB) BindProjectExecutor

func (d *DB) BindProjectExecutor(projectPath, executorID, boundBy string) error

BindProjectExecutor pins projectPath to executorID. The executor must already exist, so a typo cannot strand a project on a nonexistent backend.

func (*DB) BrokerMeta

func (d *DB) BrokerMeta(key string) (string, bool, error)

BrokerMeta reads a broker-scoped metadata value.

func (*DB) ClaimExecutorSessionRequeue

func (d *DB) ClaimExecutorSessionRequeue(id, claimToken, newClaimToken string, at time.Time) (ExecutorSessionRow, error)

ClaimExecutorSessionRequeue atomically claims a running session for failover and rotates its claim token.

The exactly-once guarantee lives entirely in this statement's WHERE clause. SQLite serialises writers, so of N supervisors racing to fail over the same dead executor exactly one finds state = 'running' with the claim token it read and updates a row; every other supervisor matches nothing and gets ErrExecutorSessionClaimLost. Rotating claim_token in the same statement also makes a retry by the winner idempotent-safe: the old token can never match again.

A read-then-write implementation would let two supervisors both decide the session is running and both requeue the work — which here means two AI agents editing the same repository concurrently. That is the failure this function exists to make impossible, so the read and the write must not be separable.

The follow-up read on a zero-row update is only to tell the operator which of two situations they are in; it never grants a claim, so a row appearing or vanishing between the two statements cannot turn a loss into a win.

func (*DB) ClearHubLease

func (d *DB) ClearHubLease(scope string, expect HubLeaseRow, at time.Time) (bool, error)

ClearHubLease marks a lease released on behalf of an operator, conditioned on the row still matching expect.

This backs the `cloop hub lease clear` escape hatch. The compare-and-swap is what makes the command's staleness gate meaningful: the caller checks that the lease is stale and then clears it, and if the holder heartbeats in between, the observed heartbeat no longer matches and the clear is refused. Without it the gate would be advisory and the command could evict a hub that had just come back.

func (*DB) ClearKill

func (d *DB) ClearKill(taskID int) error

ClearKill removes the kill row for taskID. Idempotent — clearing a row that does not exist returns nil.

func (*DB) Close

func (d *DB) Close() error

Close releases the database connection.

func (*DB) CloseExecutorSession

func (d *DB) CloseExecutorSession(id, state string, at time.Time) error

CloseExecutorSession moves a session to a terminal state. Returns ErrExecutorSessionNotFound when the ID is unknown, so a driver reporting completion for a session the control plane never opened surfaces loudly instead of being silently dropped.

func (*DB) CountBrokerSecretsByKey

func (d *DB) CountBrokerSecretsByKey() (map[string]int, error)

CountBrokerSecretsByKey groups secrets by the key they are sealed under.

func (*DB) CountEvents

func (d *DB) CountEvents() (int, error)

CountEvents returns the total number of journal rows. Cheap; used by the UI to decide whether to refresh the top page.

func (*DB) CountRunningExecutorSessions

func (d *DB) CountRunningExecutorSessions(executorID string) (int, error)

CountRunningExecutorSessions returns how many sessions are still in flight. An empty executorID counts across the whole fleet.

func (*DB) CountSessionsByKey

func (d *DB) CountSessionsByKey() (map[string]int, error)

CountSessionsByKey groups sessions holding a refresh token by sealing key.

Sessions with no refresh token are excluded: they hold nothing sealed, so counting them would make a rotation look permanently incomplete on a hub whose IdP issues no refresh tokens.

func (*DB) CurrentSchemaVersion

func (d *DB) CurrentSchemaVersion() (int, error)

CurrentSchemaVersion returns the highest migration version recorded in the database. Useful for diagnostic commands (cloop db verify, cloop migrate status). Acquires the DB mutex like other read helpers in this package.

func (*DB) DeleteBrokerSecret

func (d *DB) DeleteBrokerSecret(id string) error

DeleteBrokerSecret removes a secret. Deleting one that does not exist is an error, so a CLI can tell the operator their reference was wrong instead of reporting a successful no-op.

func (*DB) DeleteExecutor

func (d *DB) DeleteExecutor(id string) error

DeleteExecutor removes an executor and every project binding that pointed at it. Both happen in one transaction so a project can never be left pinned to an executor that no longer exists — such a project would fail every Resolve until an operator noticed.

func (*DB) DeleteExecutorHandle

func (d *DB) DeleteExecutorHandle(handleID string) error

DeleteExecutorHandle forgets one row. Deleting a row that is not there is not an error: a terminal transition can be recorded twice (once by the log pump, once by an explicit reap) and neither call should fail.

func (*DB) DeleteExecutorHealth

func (d *DB) DeleteExecutorHealth(executorID string) error

DeleteExecutorHealth forgets the health record for executorID. Deleting a row that was never written is not an error: de-enrolling an executor that was never probed must not fail.

func (*DB) DeleteExpiredSessions

func (d *DB) DeleteExpiredSessions(absoluteCutoff, idleCutoff time.Time) ([]SessionRow, error)

DeleteExpiredSessions removes sessions past either clock and returns what it deleted.

absoluteCutoff is compared against expires_at (the ceiling set at login) and idleCutoff against last_seen. Both are absolute times computed by the caller from its configured policy, so this layer stays free of any opinion about how long a session should live.

func (*DB) DeleteQuotaOverride

func (d *DB) DeleteQuotaOverride(identity string) (bool, error)

DeleteQuotaOverride removes one identity's override, reporting whether a row existed.

func (*DB) DeleteSecretLeaseDir

func (d *DB) DeleteSecretLeaseDir(dir string) error

DeleteSecretLeaseDir forgets one directory, after it has been wiped.

Deleting a row that is not there is not an error: a lease can be wiped twice — once when its workload exits, once by a revocation that raced it — and neither call should fail.

func (*DB) DeleteSession

func (d *DB) DeleteSession(id string) (bool, error)

DeleteSession removes one session and reports whether a row existed.

func (*DB) DeleteSessionsBySubject

func (d *DB) DeleteSessionsBySubject(subject, keepID string) ([]SessionRow, error)

DeleteSessionsBySubject removes every session belonging to subject except the one named by keepID (pass "" to remove all of them), returning the rows it deleted so the caller can audit each one.

Read-then-delete inside one transaction: an audit entry the operator cannot tie to a specific session is not much of an audit entry, and doing it in two statements outside a transaction would let a login between them produce a row that is deleted but never recorded.

func (*DB) DeleteTask

func (d *DB) DeleteTask(id int) error

DeleteTask removes a single task by id. Returns nil even if the row did not exist — the post-condition (task absent) is satisfied either way.

func (*DB) GetAPIToken

func (d *DB) GetAPIToken(id string) (APITokenRow, error)

GetAPIToken returns one token by ID, or ErrAPITokenNotFound.

This is the verification path's only read, which is why the lookup is by primary key: an authenticated request must not cost a table scan, and a scan would additionally make request latency a function of how many tokens the hub has ever issued.

func (*DB) GetAgentCredential

func (d *DB) GetAgentCredential(agentID string) (AgentCredentialRow, error)

GetAgentCredential returns one agent by ID, or ErrAgentCredentialNotFound.

func (*DB) GetBrokerGrant

func (d *DB) GetBrokerGrant(id string) (BrokerGrantRow, error)

GetBrokerGrant returns one grant by ID.

func (*DB) GetBrokerSecret

func (d *DB) GetBrokerSecret(id string) (BrokerSecretRow, error)

GetBrokerSecret returns one secret by ID.

func (*DB) GetConfigBlob

func (d *DB) GetConfigBlob() (string, error)

GetConfigBlob returns the YAML-serialised project config previously stored via SetConfigBlob. Returns ("", nil) when no blob has been written yet (fresh project, or DB created before this column existed).

func (*DB) GetEgressGrant

func (d *DB) GetEgressGrant(id string) (EgressGrantRow, error)

GetEgressGrant returns one grant by ID.

func (*DB) GetEnrollmentToken

func (d *DB) GetEnrollmentToken(id string) (EnrollmentTokenRow, error)

GetEnrollmentToken returns one token by ID, or ErrEnrollmentNotFound.

func (*DB) GetExecutor

func (d *DB) GetExecutor(id string) (ExecutorRow, error)

GetExecutor returns one executor by ID, or ErrExecutorNotFound.

func (*DB) GetExecutorHandle

func (d *DB) GetExecutorHandle(handleID string) (ExecutorHandleRow, error)

GetExecutorHandle returns one row, or ErrExecutorHandleNotFound.

func (*DB) GetExecutorHealth

func (d *DB) GetExecutorHealth(executorID string) (ExecutorHealthRow, error)

GetExecutorHealth returns one health row, or ErrExecutorHealthNotFound.

"Never probed" is deliberately an error rather than a zero-valued ready row: a caller that cannot tell the two apart would treat an unobserved executor as healthy, which is the failure mode this table exists to prevent.

func (*DB) GetExecutorSession

func (d *DB) GetExecutorSession(id string) (ExecutorSessionRow, error)

GetExecutorSession returns one session by ID, or ErrExecutorSessionNotFound.

func (*DB) GetHubLease

func (d *DB) GetHubLease(scope string) (HubLeaseRow, error)

GetHubLease returns the row for scope, or ErrHubLeaseNotFound.

func (*DB) GetSession

func (d *DB) GetSession(id string) (SessionRow, error)

GetSession returns one session by id, or an error wrapping ErrSessionNotFound.

This sits on the authentication path, so the lookup is by primary key: a signed-in request must not cost a scan, and a scan would make request latency a function of how many people are logged in.

func (*DB) HasProjectState

func (d *DB) HasProjectState() (bool, error)

HasProjectState reports whether any project state has ever been written to this database — that is, whether the metadata table holds a row.

It distinguishes a database that merely *exists* from one that holds a project. Opening a state.db creates the file and every table in it, so file existence alone says nothing: several code paths open a project's database for reasons unrelated to its state (executor reconciliation does it on every CLI invocation), and each of them leaves behind a complete but empty database. Callers that need "is there a project here" must ask this.

func (*DB) LastMaintenanceLog

func (d *DB) LastMaintenanceLog() (*MaintenanceLogEntry, error)

LastMaintenanceLog returns the most recent maintenance_log row. Returns (nil, nil) when no maintenance has been recorded yet — callers should treat that as "schedule a maintenance run on first invocation".

func (*DB) LatestAuditAnchor

func (d *DB) LatestAuditAnchor() (AuditAnchor, error)

LatestAuditAnchor returns the most recent anchor, or ErrAuditAnchorNotFound when the trail has never been pruned.

"Most recent" is by pruned_through_id rather than by id: the anchor that governs the live table is the one that cut furthest into it, and ordering by the boundary rather than by insertion order means an out-of-order write cannot make a stale anchor authoritative.

func (*DB) ListAPITokens

func (d *DB) ListAPITokens() ([]APITokenRow, error)

ListAPITokens returns every token, newest first. Revoked and expired rows are included: an operator auditing access needs to see what was withdrawn, and the caller decides how to present it.

func (*DB) ListAgentCredentials

func (d *DB) ListAgentCredentials() ([]AgentCredentialRow, error)

ListAgentCredentials returns every enrolled agent, oldest first.

func (*DB) ListAuditAnchors

func (d *DB) ListAuditAnchors() ([]AuditAnchor, error)

ListAuditAnchors returns every anchor in ascending boundary order. Used by `cloop hub audit anchors` and by the verifier's report so an operator can see the full retention history, not just the newest cut.

func (*DB) ListAuditEvents

func (d *DB) ListAuditEvents(f AuditFilter) (rows []AuditEvent, total int, err error)

ListAuditEvents returns rows matching the filter, plus the unfiltered total count for paging UIs.

func (*DB) ListBrokerGrants

func (d *DB) ListBrokerGrants() ([]BrokerGrantRow, error)

ListBrokerGrants returns every grant, including revoked and expired ones.

Filtering is the broker's job, not the store's: an audit UI listing "who had access" needs the rows a policy filter would drop, and a store that silently hid them would make that question unanswerable.

func (*DB) ListBrokerSecrets

func (d *DB) ListBrokerSecrets() ([]BrokerSecretRow, error)

ListBrokerSecrets returns every stored secret, name-ordered.

func (*DB) ListBrokerSecretsNotUnderKey

func (d *DB) ListBrokerSecretsNotUnderKey(keyID string, limit int) ([]SealedRow, error)

ListBrokerSecretsNotUnderKey returns secrets still sealed under some other key. Ordered by ID so a stuck row appears in the same position each round, which makes an operator's "it keeps failing on the same one" observation true rather than coincidental.

func (*DB) ListEgressGrants

func (d *DB) ListEgressGrants() ([]EgressGrantRow, error)

ListEgressGrants returns every grant, including revoked and expired ones.

Filtering is the broker's job for the same reason it is in ListBrokerGrants: an audit reader asking "who could reach the Internet last quarter" needs exactly the rows a policy filter would drop.

func (*DB) ListEnrollmentTokens

func (d *DB) ListEnrollmentTokens() ([]EnrollmentTokenRow, error)

ListEnrollmentTokens returns every token, newest first.

func (*DB) ListEvents

func (d *DB) ListEvents(offset, limit int) (rows []EventRow, total int, err error)

ListEvents returns events in reverse-chronological order (latest first). limit caps the page size; offset skips past the most recent N. total is the total number of events in the journal so callers can drive infinite- scroll UIs.

func (*DB) ListExecutorHandles

func (d *DB) ListExecutorHandles(executorID string) ([]ExecutorHandleRow, error)

ListExecutorHandles returns rows owned by executorID, oldest first. Passing "" returns every row, which is what a cross-driver sweep wants.

Oldest-first matters: an orphan sweep applies a grace period to started_at, and processing in dispatch order means the first row it examines is the one most likely to have aged past it.

func (*DB) ListExecutorHealth

func (d *DB) ListExecutorHealth() ([]ExecutorHealthRow, error)

ListExecutorHealth returns every health row ordered by executor ID.

func (*DB) ListExecutorSessions

func (d *DB) ListExecutorSessions(executorID string, onlyRunning bool) ([]ExecutorSessionRow, error)

ListExecutorSessions returns sessions ordered by start time then ID. An empty executorID lists every executor's sessions; onlyRunning restricts the result to work that is still in flight, which is what failover and capacity checks ask for.

func (*DB) ListExecutors

func (d *DB) ListExecutors() ([]ExecutorRow, error)

ListExecutors returns every persisted executor ordered by ID.

func (*DB) ListKEKs

func (d *DB) ListKEKs() ([]KEKRow, error)

ListKEKs returns every KEK, newest first.

func (*DB) ListProjectExecutorBindings

func (d *DB) ListProjectExecutorBindings() ([]ProjectExecutorBinding, error)

ListProjectExecutorBindings returns every project→executor binding, ordered by project path.

func (*DB) ListProviderCalls

func (d *DB) ListProviderCalls(offset, limit, taskID int, provider string) (rows []ProviderCallRow, total int, err error)

ListProviderCalls returns up to limit rows ordered most-recent-first, optionally filtered. The summary form omits the heavy prompt/response/headers columns so the list endpoint stays cheap even when the table grows large.

Filters:

  • taskID > 0: only calls bound to that task
  • provider != "": only calls from that provider

Returns total = count of all matching rows (regardless of limit/offset) so the UI can render pagination controls.

func (*DB) ListQuotaCounters

func (d *DB) ListQuotaCounters() ([]QuotaCounterRow, error)

ListQuotaCounters returns every counter row.

func (*DB) ListQuotaOverrides

func (d *DB) ListQuotaOverrides() ([]QuotaOverrideRow, error)

ListQuotaOverrides returns every override, ordered by identity so the admin panel renders the same way on every load.

func (*DB) ListRotations

func (d *DB) ListRotations(limit int) ([]RotationRow, error)

ListRotations returns rotation records, newest first.

func (*DB) ListSecretLeaseDirs

func (d *DB) ListSecretLeaseDirs() ([]SecretLeaseDirRow, error)

ListSecretLeaseDirs returns every recorded directory, oldest first.

At startup this is precisely the orphan set: a hub that shut down cleanly deleted its rows as it wiped, so anything still here outlived the process that created it.

func (*DB) ListSessions

func (d *DB) ListSessions() ([]SessionRow, error)

ListSessions returns every session, most recently active first.

func (*DB) ListSessionsNotUnderKey

func (d *DB) ListSessionsNotUnderKey(keyID string, limit int) ([]SealedRow, error)

ListSessionsNotUnderKey returns sessions whose sealed refresh token is under some other key.

func (*DB) LoadProviderCall

func (d *DB) LoadProviderCall(id int64) (*ProviderCallRow, error)

LoadProviderCall returns the full row (including prompt, response, headers) for a single audit-log id. Returns sql.ErrNoRows-wrapped error when missing.

func (*DB) LoadState

func (d *DB) LoadState() (*State, error)

func (*DB) LoadStateLite

func (d *DB) LoadStateLite() (*State, error)

LoadStateLite is like LoadState but skips loading the per-step rows (which on long-running projects can be megabytes of Output strings). The returned State has Steps == nil; StepCount and LastStepTime are filled from cheap aggregate SQL so handlers that only render counts or check last-activity for health don't pay for decoding every Output (Task 20125).

func (*DB) LoadTask

func (d *DB) LoadTask(id int) (*pm.Task, error)

LoadTask returns a single task by ID. Returns ErrTaskNotFound if the task does not exist. Useful from HTTP handlers where the typical 404 path is "the requested task does not exist".

func (*DB) LookupKill

func (d *DB) LookupKill(taskID int) (KillRequest, bool, error)

LookupKill returns the pending kill for taskID and whether a row exists. Used by the orchestrator after a worker exits to recover the operator's chosen target_status. Returns (KillRequest{}, false, nil) when no row.

func (*DB) MaxAuditID

func (d *DB) MaxAuditID() (int64, error)

MaxAuditID returns the largest id currently in the table, or 0 if empty. Used by the `events tail --follow` poller to discover new rows.

func (*DB) MonthlyCosts

func (d *DB) MonthlyCosts(year, month int) ([]CostEntry, error)

MonthlyCosts returns cost entries for the given UTC year/month.

func (*DB) OpenExecutorSession

func (d *DB) OpenExecutorSession(row ExecutorSessionRow) error

OpenExecutorSession records work starting on an executor.

This is a plain INSERT, not an upsert: a session id identifies one attempt, and silently overwriting an existing row would erase the claim token a supervisor may already be holding for the in-flight attempt.

func (*DB) PendingKills

func (d *DB) PendingKills() ([]KillRequest, error)

PendingKills returns all currently-pending abort requests, oldest first. The orchestrator's poller calls this every fast tick.

func (*DB) PingContext

func (d *DB) PingContext(ctx context.Context) error

PingContext runs a `SELECT 1` against the underlying connection, honouring ctx for cancellation/deadline. Used by readiness probes (e.g. /readyz) to verify the SQLite store is reachable.

Intentionally does NOT acquire d.mu: *sql.DB is concurrency-safe, and the mutex is reserved for transactional read-modify-write ops in this package. Holding it here would block readiness on whichever long-running write happens to own the lock — exactly what a probe must NOT do. The ctx timeout bounds wait time on the single underlying connection (we set MaxOpenConns(1)) when it is busy.

func (*DB) PlanAuditPrune

func (d *DB) PlanAuditPrune(cutoff time.Time) (AuditPruneCandidate, error)

PlanAuditPrune resolves a time cutoff to a contiguous id prefix.

Rows strictly older than cutoff are eligible. The boundary is the largest such id, and because ids are assigned monotonically under d.mu while timestamps come from the same clock, "older than cutoff" and "id <= boundary" describe the same set — but we count the set explicitly rather than assuming it, so a clock that went backwards produces a visibly wrong count instead of a silently over-broad delete.

func (*DB) PrimaryKEK

func (d *DB) PrimaryKEK() (KEKRow, bool, error)

PrimaryKEK returns the current primary, if any.

Separate from ListKEKs because it sits on the seal path: every seal re-reads it to confirm this process has not fallen behind a rotation run elsewhere, so it has to be one indexed lookup and not a scan.

func (*DB) ProjectExecutor

func (d *DB) ProjectExecutor(projectPath string) (string, bool, error)

ProjectExecutor returns the executor ID bound to projectPath. The boolean is false when the project has no binding, which callers treat as "use the registry default" — distinct from an error, which means the lookup itself failed and must not be interpreted as "unbound".

func (*DB) PromoteKEK

func (d *DB) PromoteKEK(id string, at time.Time) error

PromoteKEK makes id the sole primary.

Demotion and promotion happen in one transaction because the partial unique index permits only one primary: doing them in two statements outside a transaction would leave a window with none (a hub starting in that window mints a third key) or, if ordered the other way, fail on the index. The index is what makes this correct under concurrency rather than merely usually correct.

func (*DB) PruneAuditPrefix

func (d *DB) PruneAuditPrefix(a AuditAnchor) (AuditAnchor, error)

PruneAuditPrefix deletes audit_events with id <= throughID and records an anchor describing the cut.

The caller must already have sealed the prefix; this function will not delete rows without an export digest, because an anchor with no evidence behind it is indistinguishable from a cover-up and the whole point of the anchor is to be checkable. Pass a.ExportSHA256 and a.ExportPath from the completed seal.

Delete and anchor happen in one transaction: a crash between them would otherwise leave either a truncated chain nothing explains, or an anchor claiming a truncation that did not happen. Both verify as tampering, and both would be this function's fault rather than an attacker's.

Returns the anchor as written, with ID, RetainedFromID and the hashes filled in.

func (*DB) PruneQuotaCounters

func (d *DB) PruneQuotaCounters(bucket string) error

PruneQuotaCounters deletes daily counters older than bucket. Gauge rows (bucket = ”) are never pruned: they are live state, not history, and ” sorts before every date so an unguarded comparison would delete all of them.

func (*DB) PutAPIToken

func (d *DB) PutAPIToken(row APITokenRow) error

PutAPIToken inserts a token row. It is an insert, not an upsert: a token's secret, roles, and scope are fixed at mint time, and silently rewriting an existing row would turn "re-run the create call" into an undetectable privilege change on a credential already in circulation. Rotating means minting a new token and revoking the old one.

func (*DB) PutAgentCredential

func (d *DB) PutAgentCredential(row AgentCredentialRow) error

PutAgentCredential inserts or updates an agent credential row.

func (*DB) PutBrokerGrant

func (d *DB) PutBrokerGrant(row BrokerGrantRow) error

PutBrokerGrant inserts or replaces a grant.

func (*DB) PutBrokerSecret

func (d *DB) PutBrokerSecret(row BrokerSecretRow) error

PutBrokerSecret inserts or replaces a secret.

The unique index on name is what makes a duplicate a storage error rather than a silently-shadowed second row, so a name collision surfaces here even if two processes race past the broker's own check.

func (*DB) PutEgressGrant

func (d *DB) PutEgressGrant(row EgressGrantRow) error

PutEgressGrant inserts or replaces an egress grant.

func (*DB) PutEnrollmentToken

func (d *DB) PutEnrollmentToken(row EnrollmentTokenRow) error

PutEnrollmentToken inserts or updates a token row.

func (*DB) PutExecutorHandle

func (d *DB) PutExecutorHandle(row ExecutorHandleRow) error

PutExecutorHandle inserts or replaces the row for row.HandleID.

Unlike OpenExecutorSession this *is* an upsert. A driver may re-record a handle it already owns — rehydration re-writes rows so updated_at reflects the adopting control plane, and a Start that retries against the same handle id must not fail on a duplicate key. There is no claim token here to clobber: the row carries identity, not ownership.

func (*DB) PutExecutorHealth

func (d *DB) PutExecutorHealth(row ExecutorHealthRow) error

PutExecutorHealth inserts or updates the health row for row.ExecutorID.

The row is written wholesale rather than merged field-by-field: the prober computes the complete verdict (state, reason, failure count, transition time) in one place, and a partial update would let a stale field outlive the observation that produced it.

func (*DB) PutKEK

func (d *DB) PutKEK(row KEKRow) error

PutKEK inserts or replaces a KEK record.

The WHERE clause on the upsert makes retirement terminal. Without it a PutKEK against a retired id would restore its salt and state, un-doing a crypto-shred with an ordinary write — the one operation in this file that is supposed to be irreversible.

func (*DB) PutQuotaCounter

func (d *DB) PutQuotaCounter(row QuotaCounterRow) error

PutQuotaCounter writes one counter's current value. A value at or below zero deletes the row instead of storing a zero: an identity holding nothing should not keep a row per resource forever, and a row that says 0 and a missing row must not be two different things the reader has to reconcile.

func (*DB) PutQuotaOverride

func (d *DB) PutQuotaOverride(row QuotaOverrideRow) error

PutQuotaOverride inserts or replaces one identity's override.

func (*DB) PutRotation

func (d *DB) PutRotation(row RotationRow) error

PutRotation inserts or updates a rotation record.

func (*DB) PutSecretLeaseDir

func (d *DB) PutSecretLeaseDir(row SecretLeaseDirRow) error

PutSecretLeaseDir records the intent to materialise a lease at row.Dir.

Call it before anything writes plaintext there. The ordering is the whole point of the table: a row without a directory is harmless and self-clearing, a directory without a row is an orphan nothing can attribute.

func (*DB) PutSession

func (d *DB) PutSession(row SessionRow) error

PutSession inserts a session row.

An insert rather than an upsert: the id is a digest of 256 random bits, so a collision is not a case worth writing code for, while silently overwriting one would replace a live session's subject and roles with another's.

func (*DB) ReadAuditRange

func (d *DB) ReadAuditRange(fromID, throughID int64, limit int) ([]AuditEvent, error)

ReadAuditRange returns up to limit rows with fromID <= id <= throughID, in ascending id order.

It exists next to ListAuditEvents rather than reusing it because ListAuditEvents runs an unfiltered COUNT(*) on every call to populate its paging total. That is right for a UI page and wrong for a cursor: sealing a 1.09M-row prefix in batches would run a full-table count per batch. Paging by id rather than by OFFSET matters for the same reason — OFFSET re-walks the skipped rows every time.

func (*DB) ReadCosts

func (d *DB) ReadCosts() ([]CostEntry, error)

ReadCosts returns all cost entries ordered by timestamp ascending.

func (*DB) ReadCostsSince

func (d *DB) ReadCostsSince(since time.Time) ([]CostEntry, error)

ReadCostsSince returns cost entries with timestamp >= since, ordered ascending.

func (*DB) RecordEvent

func (d *DB) RecordEvent(row EventRow) error

RecordEvent appends one row to the events journal. Best-effort: the caller is expected to ignore the returned error (event-recording must never block the orchestrator). Idempotency is NOT guaranteed — duplicate calls record duplicate rows. The auto-increment id ordering is the source of truth for "what happened first" on a single host.

func (*DB) RedeemEnrollmentToken

func (d *DB) RedeemEnrollmentToken(id, agentID string, at time.Time) error

RedeemEnrollmentToken atomically claims a token for agentID.

The single-use guarantee lives entirely in this statement's WHERE clause. SQLite serialises writers, so of two concurrent redemptions exactly one finds redeemed_at = ” and updates a row; the other matches nothing and gets ErrEnrollmentSpent. A read-then-write implementation would let both succeed and hand two devices the same identity, which is the precise attack that single-use tokens exist to prevent.

func (*DB) ReleaseHubLease

func (d *DB) ReleaseHubLease(scope, instanceID string, at time.Time) (bool, error)

ReleaseHubLease gives up a lease held by instanceID, leaving the row in place with released_at set.

Scoped to instanceID so a late release from a hub that already lost the lease cannot free the lease its successor now holds — the same fencing discipline as RenewHubLease, applied to the path most likely to run last.

func (*DB) RenewHubLease

func (d *DB) RenewHubLease(scope, instanceID string, at time.Time) (bool, error)

RenewHubLease advances the heartbeat for a lease this instance holds.

Returning false is the fencing signal, and callers must treat it as such: it means the row is no longer ours — taken over while we were paused, cleared by an operator, or released by us already — and the process must stop acting as the control plane rather than retry. It is deliberately not an error, because it is not one: it is an answer.

func (*DB) ReplaceBrokerSecretSealed

func (d *DB) ReplaceBrokerSecretSealed(id string, expect, next SealedRow) (bool, error)

ReplaceBrokerSecretSealed swaps a secret's envelope under compare-and-swap.

The WHERE clause matches the old key ID *and the exact ciphertext*, not just the ID. Matching the key alone would be a lost-update bug with teeth: a rotation that read a row, and an operator who re-minted that credential while the rotation was mid-flight, would race — and the rotation would win, silently restoring the superseded credential under a fresh wrap, with every column looking correct afterwards. Comparing the bytes turns that race into a no-op the rotator counts as skipped.

func (*DB) ReplaceQuotaGauges

func (d *DB) ReplaceQuotaGauges(rows []QuotaCounterRow) error

ReplaceQuotaGauges swaps every gauge row (bucket = ”) for rows in one transaction.

A transaction rather than delete-then-insert because the intermediate state — no gauge counters at all — is a state in which every tenant has unlimited headroom. On a hub reconciling at startup that window is short, but it is exactly the window an attacker watching for a restart would aim at, and making it atomic costs nothing.

func (*DB) ReplaceSessionSealed

func (d *DB) ReplaceSessionSealed(id string, expect, next SealedRow) (bool, error)

ReplaceSessionSealed swaps a session's sealed refresh token under compare-and-swap, with the same reasoning as above: a session that refreshed its token mid-rotation must not have the previous token restored.

func (*DB) RequestKill

func (d *DB) RequestKill(req KillRequest) error

RequestKill upserts one abort request. Repeated calls for the same task ID overwrite target_status / requested_at — the latest request wins. Returns nil when the row was written.

func (*DB) RetireKEK

func (d *DB) RetireKEK(id string, at time.Time) error

RetireKEK marks a KEK retired and destroys its salt.

The reference check runs inside the retiring transaction, which is the difference between a guarantee and a race: checking first and retiring after would let a lease minted in between end up sealed under a key whose salt is already gone, and that material would be unrecoverable with no error anywhere to explain it.

Blanking the salt is the point of retirement. A state flag alone could be ignored by a future code path or flipped back by a hand-edited database; without the salt the key cannot be derived from the passphrase at all, so retirement is a property of the cryptography rather than of the control flow.

func (*DB) RevokeAPIToken

func (d *DB) RevokeAPIToken(id string, at time.Time) error

RevokeAPIToken stamps a token as revoked. Revoking an already-revoked token keeps the original timestamp: the first withdrawal is the one that matters, and overwriting it would let a later call quietly move the date an auditor reads as "when did this stop working".

Returns ErrAPITokenNotFound when no such token exists, so a caller cannot mistake a typo for a successful revocation.

func (*DB) RevokeAgentCredential

func (d *DB) RevokeAgentCredential(agentID string, at time.Time) error

RevokeAgentCredential marks an agent's credential revoked.

func (*DB) RevokeBrokerGrant

func (d *DB) RevokeBrokerGrant(id string, at time.Time) error

RevokeBrokerGrant stamps a grant revoked.

The UPDATE is conditional on revoked_at being empty so a second revoke cannot move the timestamp forward. That matters for the audit trail: the moment access was withdrawn is a fact, and a retry must not rewrite it. Revoking an already-revoked grant is reported as success, because the caller's desired end state holds.

func (*DB) RevokeEgressGrant

func (d *DB) RevokeEgressGrant(id string, at time.Time) error

RevokeEgressGrant stamps a grant revoked.

Conditional on revoked_at being empty, matching RevokeBrokerGrant: the moment access was withdrawn is a fact, and a retry must not move it. Revoking an already-revoked grant reports success, because the caller's desired end state holds.

func (*DB) RevokeEnrollmentToken

func (d *DB) RevokeEnrollmentToken(id string, at time.Time) error

RevokeEnrollmentToken marks a token revoked, redeemed or not.

func (*DB) SaveState

func (d *DB) SaveState(s *State) error

func (*DB) SchemaStamp

func (d *DB) SchemaStamp() (SchemaStamp, error)

SchemaStamp returns provenance for the highest version recorded in this database: which migration last moved the schema, when, and by which build.

Returns a zero SchemaStamp and no error when the database has no recorded migrations at all — a brand-new file, which is not a fault.

func (*DB) SessionsDueForRefresh

func (d *DB) SessionsDueForRefresh(cutoff time.Time, limit int) ([]SessionRow, error)

SessionsDueForRefresh returns sessions whose IdP check is older than cutoff and that still hold a sealed refresh token, oldest first and at most limit rows.

The limit bounds one pass: a hub where every session comes due at once must spread the token-endpoint calls over several ticks rather than opening a thousand connections to the IdP in one burst, which the IdP would rightly treat as an attack.

func (*DB) SetBrokerMeta

func (d *DB) SetBrokerMeta(key, value string) error

SetBrokerMeta writes a broker-scoped metadata value.

func (*DB) SetConfigBlob

func (d *DB) SetConfigBlob(yamlBlob string) error

SetConfigBlob persists the YAML-serialised project config into the metadata table. The write is wrapped in a transaction so a crash mid-write leaves either the previous value or the new one — never a partial row.

func (*DB) SizeStats

func (d *DB) SizeStats() (SizeStats, error)

SizeStats reads PRAGMA page_count, page_size, and freelist_count.

Connection-scoped pragmas — modernc.org/sqlite returns one row per query. We hold d.mu so the three reads stay consistent against concurrent writers in this process; concurrent writers in *other* processes can still extend the file between the page_count and freelist_count reads, but the resulting values remain individually valid (worst case: a slightly stale Bytes total).

func (*DB) TouchAPIToken

func (d *DB) TouchAPIToken(id string, at time.Time) error

TouchAPIToken records that a token was used. Called off the verification path and coalesced by pkg/apitoken, so a failure here is logged by the caller and never surfaces as an authentication error — a lost timestamp is not a lost authorization decision.

func (*DB) TouchAgentCredential

func (d *DB) TouchAgentCredential(agentID string, at time.Time) error

TouchAgentCredential records a successful authentication.

func (*DB) TouchExecutorHeartbeat

func (d *DB) TouchExecutorHeartbeat(id, status string, at time.Time) error

TouchExecutorHeartbeat records liveness for an enrolled executor and updates its status. Returns ErrExecutorNotFound when the ID is unknown, so a stale agent cannot resurrect a de-enrolled record by heartbeating.

func (*DB) TouchSession

func (d *DB) TouchSession(id string, t time.Time) error

TouchSession records that a session was used at t.

Called off the authentication path and throttled by the caller; a failure is advisory. The write is conditional on moving the timestamp forward so two concurrent requests cannot walk last_seen backwards — the ordering the caller's in-memory cache enforces within one process, enforced again here for the cross-process case.

func (*DB) UnbindProjectExecutor

func (d *DB) UnbindProjectExecutor(projectPath string) error

UnbindProjectExecutor removes the binding for projectPath. Unbinding a project that was never bound is not an error.

func (*DB) UpdateSessionRefresh

func (d *DB) UpdateSessionRefresh(id, keyID string, wrappedDEK, sealed []byte, checkedAt time.Time) error

UpdateSessionRefresh stores a rotated refresh token and stamps the check time. A nil sealed value clears the stored token, which is how a session that can no longer be revalidated stops being retried.

Clearing also blanks the key id. Leaving a stale one behind would make the row look, to the rotator and to `cloop hub key retire`, like material still sealed under a key that in fact protects nothing — which is exactly the kind of phantom reference that makes an operator conclude retirement is broken.

func (*DB) UpsertExecutor

func (d *DB) UpsertExecutor(row ExecutorRow) error

UpsertExecutor inserts or updates an executor row keyed by ID. CreatedAt is preserved on update: re-enrolling an agent must not rewrite its enrollment date.

func (*DB) UpsertTask

func (d *DB) UpsertTask(t *pm.Task) error

func (*DB) Vacuum

func (d *DB) Vacuum() error

Vacuum runs a full VACUUM on the database. Cannot be inside a transaction; the autocommit code path in modernc.org/sqlite handles this correctly. WAL mode is preserved across the VACUUM (SQLite re-applies the journal_mode pragma internally).

func (*DB) VacuumInto

func (d *DB) VacuumInto(outPath string) error

VacuumInto produces a transactionally-consistent copy of the database at outPath using SQLite's `VACUUM INTO 'path'` statement. The output is a single self-contained file (no WAL/SHM sidecars) and is the recommended hot-backup primitive when the C-level Online Backup API is not available.

outPath must not already exist — VACUUM INTO refuses to overwrite. The caller is responsible for clearing any stale destination first.

The path is interpolated into a SQL string literal because VACUUM INTO does not accept bound parameters. Single quotes in the path are escaped by doubling, matching SQLite's quoting rules.

func (*DB) VerifyAuditChain

func (d *DB) VerifyAuditChain() (AuditVerifyReport, error)

VerifyAuditChain walks audit_events in id order and recomputes each row's hash, comparing to the stored value. The first mismatch (or gap, or missing prev_hash linkage) stops the walk and is reported. Verification is read-only and holds the DB mutex only during the row scan.

A pruned chain (Task 20218) does not start at genesis, and the naive walk would report the first survivor as tampered. When an anchor exists the walk starts from it instead: the first row must be exactly the id the anchor pinned, and its prev_hash must be the row_hash of the last row the anchor removed. That is a stronger check than "tolerate a short chain" — it means a second, undeclared truncation just after the boundary is still caught, because the anchor says where the chain has to resume.

What this cannot do is prove the pruned prefix itself was honest; nothing inside the database can, since an attacker with write access could author both the truncation and the anchor. That is what the export digest is for, and VerifyAuditExports checks it.

func (*DB) WALCheckpointTruncate

func (d *DB) WALCheckpointTruncate() (string, error)

WALCheckpointTruncate flushes the WAL into the main database file and truncates the WAL to zero bytes. Used as the first step of a hot backup (Task 20115) so VACUUM INTO produces a snapshot that includes every committed transaction up to the moment of the backup, not just whatever had previously been folded into the main file.

PRAGMA wal_checkpoint returns three columns: busy (0 if checkpoint completed without contention), log_size (frames in the WAL before the checkpoint), and checkpointed (frames moved to the main DB). The string returned summarises those values for logging; a non-zero busy column is not an error per se — under heavy concurrent load the checkpoint can only flush as many frames as it could acquire — but it is reported so an operator can decide whether to retry.

type EgressGrantRow

type EgressGrantRow struct {
	ID           string
	Scope        string
	SubjectType  string
	SubjectValue string
	PolicyJSON   string
	ExpiresAt    string
	CreatedAt    string
	CreatedBy    string
	RevokedAt    string
}

EgressGrantRow is one row of egress_grants. Times are RFC3339 strings, matching the rest of the schema.

type EnrollmentTokenRow

type EnrollmentTokenRow struct {
	ID   string
	Name string
	// SecretHash is hex SHA-256 of the token secret, never the secret.
	SecretHash      string
	WorkDirRoot     string
	Labels          map[string]string
	CreatedAt       time.Time
	ExpiresAt       time.Time
	CreatedBy       string
	RedeemedAt      time.Time
	RedeemedAgentID string
	RevokedAt       time.Time
}

EnrollmentTokenRow is one minted enrollment token.

type EventRow

type EventRow struct {
	ID        int64     // primary key, assigned on insert (zero on the way in)
	Timestamp time.Time // when the event happened
	Type      EventType
	TaskID    int    // 0 when not task-bound
	TaskTitle string // empty when not task-bound
	Step      int    // NoStep (-1) when not step-bound; 0 is a real step
	Message   string // short, human-readable summary
	Details   string // free-form JSON blob (may be empty)
}

EventRow is one row in the events table.

type EventType

type EventType string

EventType is a string enum identifying the kind of journal entry. Readers must treat unknown values as benign — the table is intentionally extensible.

const (
	EventSessionStarted    EventType = "session_started"
	EventSessionPaused     EventType = "session_paused"
	EventSessionFailed     EventType = "session_failed"
	EventPlanComplete      EventType = "plan_complete"
	EventTaskStarted       EventType = "task_started"
	EventTaskDone          EventType = "task_done"
	EventTaskFailed        EventType = "task_failed"
	EventTaskSkipped       EventType = "task_skipped"
	EventTaskHeal          EventType = "task_heal"
	EventTaskKilled        EventType = "task_killed"
	EventTaskAdded         EventType = "task_added"
	EventTaskAddedExternal EventType = "task_added_external"
	EventTaskDeleted       EventType = "task_deleted"
	EventTaskStatusChange  EventType = "task_status_change"
	EventEvolveRoundStart  EventType = "evolve_round_start"
	EventEvolveDiscovered  EventType = "evolve_discovered"
	EventEvolveNoOp        EventType = "evolve_no_op"
	// EventWriteBack records what an isolated executor returned of a task's
	// work: the branch, the commit, and whether it merged.
	//
	// Its own type rather than a field on task_done, because the two answer
	// different questions and are allowed to disagree. A task can succeed and
	// have its work fail to come back — the harness ran, the push did not —
	// and a journal that folded the second into the first would have no way to
	// show an operator the run whose transcript was right and whose code was
	// lost. Making exactly that case visible is why this subsystem exists.
	EventWriteBack EventType = "write_back"

	// EventTaskBackground records work an agent harness left running after it
	// reported the task complete (Task 20205), and how that resolved.
	//
	// Its own type rather than a detail on task_done because it explains the
	// two things a status alone cannot: why a task sat running long after its
	// agent had stopped talking, and why a task whose output ended in
	// TASK_DONE was nonetheless not accepted as done.
	EventTaskBackground EventType = "task_background"

	// EventTaskAborted records a run that never produced work: a provider
	// usage limit or quota, a rejected credential, a harness that refused to
	// start, or output with no diff and no artifact behind it (Task 20211).
	//
	// Its own type rather than task_failed because the two are different
	// claims and are acted on differently. A failure is a judgement about the
	// work; an abort is the absence of any work to judge, so the task returns
	// to pending instead of staying terminal. Folding them together is what
	// let fourteen tasks be recorded as done whose entire stored summary was
	// "You've hit your limit" — and let auto-evolve plan follow-up work on
	// the belief that they had shipped.
	EventTaskAborted EventType = "task_aborted"
)

type ExecutorAuditInput

type ExecutorAuditInput struct {
	// Action is the lifecycle verb: "enroll", "revoke", "cordon",
	// "uncordon", "drain", "bind", "unbind". It becomes the suffix of the
	// event type, so the whole family is filterable as event_type LIKE
	// 'executor.%' and each verb individually by exact match.
	Action string

	// ExecutorID is the fleet identifier the action targeted. Empty is
	// legitimate for "unbind", which names a project rather than a device.
	ExecutorID string

	// Actor is the acting identity, normally the OIDC subject label.
	Actor string

	// Detail is optional context: the device name, the cordon reason, the
	// project path a binding points at. Never credentials — the enrollment
	// token in particular must not appear here, and the central redaction
	// in MarshalAuditPayload enforces that even if a caller tries.
	Detail map[string]any
}

ExecutorAuditInput carries one executor-fleet decision to the audit log (Task 20167).

Executor lifecycle was the gap in the trail: enrolling a device grants an arbitrary machine the right to run project workloads and to request brokered credentials, and revoking, cordoning, draining, or re-binding one changes where a project's code executes. Those are exactly the events an operator needs to reconstruct — "who attached this machine, and when" — and none of them were recorded before.

type ExecutorHandleRow

type ExecutorHandleRow struct {
	HandleID   string
	ExecutorID string
	Driver     string
	// ExternalID is the name the runtime knows the workload by. Its shape is
	// driver-specific and nothing outside the owning driver interprets it.
	ExternalID  string
	ProjectPath string
	TaskID      int
	PID         int
	Image       string
	// MetaJSON is a marshalled map[string]string of driver-specific extras.
	// ” and '{}' both mean "none"; readers must tolerate both because the
	// column default is '{}' and a caller may write ”.
	MetaJSON string
	// SecretsJSON is a marshalled array of the workload's secret-lease
	// bindings — lease ids, variable names and paths, never values. This
	// package stores it as opaque text; pkg/executorstore owns the type.
	//
	// Unlike MetaJSON, ” and '[]' are *not* the same. ” means nobody
	// recorded what this workload held (a row from before 0031), and '[]'
	// means it held nothing. Collapsing them would let a revocation read an
	// unrecorded holder as a non-holder and report success; see 0031.
	SecretsJSON string
	StartedAt   time.Time
	// Deadline is when the workload's timeout expires; the zero time means
	// unbounded. Absolute, so a hub that was down does not restart the clock.
	Deadline  time.Time
	UpdatedAt time.Time
}

ExecutorHandleRow is the durable identity of one dispatched workload.

type ExecutorHealthRow

type ExecutorHealthRow struct {
	ExecutorID          string
	State               string
	Reason              string
	ConsecutiveFailures int
	LastSeen            time.Time
	LastProbe           time.Time
	StateChangedAt      time.Time
}

ExecutorHealthRow is the scheduler's probe-driven view of one executor. ExecutorID shares the identifier space with ExecutorRow.ID but is not a foreign key — see the file comment.

type ExecutorInventory

type ExecutorInventory struct {
	// AgentVersion is the cloop build the device reported at its last connect.
	// Empty means it reported none; the literal "1" means the placeholder that
	// pre-inventory agents sent. Both are "unknown", but only the second tells
	// an operator the device is definitely old.
	AgentVersion string `json:"agent_version,omitempty"`
	// OS and Arch are the device's GOOS/GOARCH.
	OS   string `json:"os,omitempty"`
	Arch string `json:"arch,omitempty"`
	// CPUs and MemoryMB are logical cores and total memory. Zero means the
	// agent could not detect it, which is never "too small".
	CPUs     int `json:"cpus,omitempty"`
	MemoryMB int `json:"memory_mb,omitempty"`
	// Harnesses are the agent CLIs on the device's PATH ("claude", "codex").
	Harnesses []string `json:"harnesses,omitempty"`
	// ContainerRuntimes are the container runtimes it can drive.
	ContainerRuntimes []string `json:"container_runtimes,omitempty"`
	// WorkDirRoot is the directory the agent confines every workload beneath.
	WorkDirRoot string `json:"workdir_root,omitempty"`
}

ExecutorInventory is what an edge device reported about itself, parsed out of its capability advertisement into typed, individually-queryable fields.

It is a nested struct rather than eight more fields on ExecutorRow because these travel together: they are refreshed as a set on every connect, they are all empty for a non-enrolled backend, and grouping them keeps "is this row's device inventory known" a single question.

Capabilities on ExecutorRow still holds the full advertisement. This is not a replacement for it but a projection of the parts that have to be selectable — see migration 0030 for why both exist.

func (ExecutorInventory) Known

func (i ExecutorInventory) Known() bool

Known reports whether anything was actually advertised. Used to decide between rendering an inventory and rendering nothing at all — a panel that showed "0 CPUs, no harnesses" for a device that never reported would be inventing a fault.

type ExecutorRow

type ExecutorRow struct {
	ID            string            `json:"id"`
	Name          string            `json:"name"`
	Kind          string            `json:"kind"`
	Endpoint      string            `json:"endpoint,omitempty"`
	Status        string            `json:"status"`
	Capabilities  json.RawMessage   `json:"capabilities,omitempty"`
	Labels        map[string]string `json:"labels,omitempty"`
	LastHeartbeat time.Time         `json:"last_heartbeat,omitempty"`
	CreatedAt     time.Time         `json:"created_at"`
	EnrolledBy    string            `json:"enrolled_by,omitempty"`
	// Inventory is the parsed, queryable subset of what a device advertised.
	// Zero for backends that were configured rather than enrolled: a container
	// driver has no build version of its own, it runs this one.
	Inventory ExecutorInventory `json:"inventory,omitzero"`
}

ExecutorRow is one persisted execution backend.

type ExecutorSessionRow

type ExecutorSessionRow struct {
	ID          string
	ExecutorID  string
	HandleID    string
	ProjectPath string
	TaskID      int
	// ClaimToken gates the requeue latch. It is a concurrency token, not a
	// secret: it is stored and compared in plaintext because it authorises
	// nothing beyond winning one race.
	ClaimToken   string
	State        string
	Attempt      int
	StartedAt    time.Time
	UpdatedAt    time.Time
	EndedAt      time.Time
	RequeuedFrom string
	// SpecJSON is the marshalled executor.Spec the session was dispatched
	// with. Failover re-dispatches from it, so it must survive a
	// control-plane restart: a session whose spec lived only in the memory
	// of the process that started it could not be requeued by the process
	// that replaces it, which is exactly when requeueing matters most.
	SpecJSON string
}

ExecutorSessionRow is one unit of work in flight on an executor.

type HubLeaseRow

type HubLeaseRow struct {
	Scope      string
	InstanceID string
	Hostname   string
	PID        int
	// BootID disambiguates a pid across reboots; ” when the platform does
	// not expose one. A liveness probe must not trust PID unless this matches
	// the probing host's current boot id.
	BootID  string
	Address string
	Version string

	AcquiredAt  time.Time
	HeartbeatAt time.Time
	// ReleasedAt is the instant of a graceful release; the zero time means the
	// lease is still held (or was abandoned without one).
	ReleasedAt time.Time
}

HubLeaseRow is one fence row: who holds the control plane, and since when.

func (HubLeaseRow) Held

func (r HubLeaseRow) Held() bool

Held reports whether the row represents a lease nobody has given up. It says nothing about whether the holder is alive — that is HeartbeatAt's job, and deciding it is pkg/hublease's.

type ImagePolicyDenialInput

type ImagePolicyDenialInput struct {
	// ProjectPath identifies the project whose .cloop/sandbox.yaml was
	// refused. It is the entity the row is filed under.
	ProjectPath string
	// Image is the reference as the project wrote it, unmodified — the
	// normalized form would hide exactly the homograph or confusion attempt
	// that is the reason to look.
	Image string
	// Rule is the imagepolicy rule that refused it ("registry-allowlist",
	// "digest-required", …). Kept as the same string the API and the error
	// text use, so filtering the trail and reading a bug report agree.
	Rule string
	// Reason is the human-readable observation.
	Reason string
	// Registry and Repository are the parsed components, empty when the
	// reference did not parse. They are what makes "show me every denial for
	// this registry" a query rather than a grep.
	Registry   string
	Repository string
	// Actor is the acting identity, normally the OIDC subject label.
	Actor string
}

ImagePolicyDenialInput carries one refused container image to the audit log (Task 20177).

This is the one sandbox decision worth a permanent row. The others describe a project asking for less than it could have; this one describes a project asking to execute code from somewhere the operator does not trust, and the question it has to answer later is "was this a typo in one repo, or did the same unknown registry get named across six projects in an hour". Only a durable, queryable trail answers the second.

type KEKRow

type KEKRow struct {
	ID         string
	Salt       string
	State      string
	CheckValue []byte
	CreatedAt  string
	CreatedBy  string
	RetiredAt  string
}

KEKRow is one row of broker_keks.

type KillRequest

type KillRequest struct {
	TaskID       int
	TargetStatus string // empty when the operator did not pick a final status
	RequestedAt  time.Time
	RequestedBy  string // free-form (UI client ID, "ui", etc.) — informational only
	// Attempt identifies the execution being aborted: the RFC3339Nano
	// started_at the requester observed on the task. The orchestrator honours
	// the row only while the task's started_at still matches, so a request
	// that outlives its run cannot cancel a later attempt of the same task.
	// Empty means "no attempt named" and is treated as stale (Task 20203).
	Attempt string
}

KillRequest is one pending manual-abort request keyed by task ID.

type MaintenanceLogEntry

type MaintenanceLogEntry struct {
	ID              int64
	Operation       string
	StartedAt       time.Time
	CompletedAt     time.Time
	PageCountBefore int64
	PageCountAfter  int64
	PageSize        int64
	BytesBefore     int64
	BytesAfter      int64
	Note            string
}

MaintenanceLogEntry mirrors one row of the maintenance_log table.

ID is auto-assigned by SQLite on insert. Operation is one of "vacuum", "analyze", or "vacuum+analyze" (we record the combined run as a single row). Note carries free-form context — typically the auto-mode reasoning string.

func (MaintenanceLogEntry) BytesFreed

func (e MaintenanceLogEntry) BytesFreed() int64

BytesFreed returns BytesBefore - BytesAfter (zero when the file did not shrink, e.g. because freelist was already empty).

type MigrateOptions

type MigrateOptions struct {
	// Target stops after the migration numbered Target. Zero means
	// LatestVersion — every embedded migration.
	Target int

	// AllowSchemaDowngrade suppresses the refusal to open a database whose
	// schema is ahead of this binary's. See schema_guard.go; the operator-
	// facing spelling is the CLOOP_ALLOW_SCHEMA_DOWNGRADE environment
	// variable, which Migrate and MigrateTo read on the caller's behalf.
	AllowSchemaDowngrade bool
}

MigrateOptions configures MigrateWithOptions.

type MigrationReport

type MigrationReport struct {
	StartVersion    int   // version recorded before this run (0 = brand new DB)
	EndVersion      int   // highest version applied (or already present)
	Applied         []int // versions newly applied during this run
	BaselineApplied bool  // true when an existing pre-framework DB was adopted at v1
}

MigrationReport summarises the outcome of a Migrate call.

func Migrate

func Migrate(db *sql.DB) (*MigrationReport, error)

Migrate brings db up to the latest embedded schema version. Safe to call repeatedly: a fully migrated database is a no-op. Each pending migration runs inside a transaction together with the schema_migrations row insert, so an interrupted run leaves the database at the previous version.

Errors are wrapped with ErrSchemaMismatch so callers can use errors.Is to distinguish migration failures from generic SQLite errors. A database ahead of this binary additionally carries ErrSchemaTooNew.

func MigrateTo

func MigrateTo(db *sql.DB, target int) (*MigrationReport, error)

MigrateTo is Migrate, stopping after the migration numbered target. It exists so that a test can construct the schema some earlier release actually shipped and then let Open() migrate it forward, which is the only way to exercise a migration against the database it will really meet.

The alternative — migrate fully, then hand-undo one migration's DDL and delete its schema_migrations row — does not work, and the way it fails is worth recording. currentVersion is MAX(version), so forgetting migration N forgets everything after it too, and they all re-run. That is harmless while every later migration is CREATE ... IF NOT EXISTS, and stops being harmless the moment one of them is an ALTER TABLE ADD COLUMN, which SQLite has no idempotent spelling for. It then fails with "duplicate column name" inside whichever unrelated test did the rewinding.

Note that target bounds how far *forward* this call migrates and has no bearing on the version-skew refusal, which always compares the database against the highest migration the binary embeds.

func MigrateWithOptions

func MigrateWithOptions(db *sql.DB, opts MigrateOptions) (*MigrationReport, error)

MigrateWithOptions is Migrate with the opt-out under the caller's control rather than the environment's. `cloop hub doctor` uses it to inspect a database the hub itself would refuse — diagnosing a skew is the one job that must still work when the skew is present.

type OpenOptions

type OpenOptions struct {
	// AllowSchemaDowngrade opens a database whose schema is ahead of this
	// binary's instead of refusing it. Open sets this from
	// CLOOP_ALLOW_SCHEMA_DOWNGRADE; diagnostics that must read a skewed
	// database in order to report on it set it directly.
	AllowSchemaDowngrade bool
}

OpenOptions configures OpenWithOptions.

type ProjectExecutorBinding

type ProjectExecutorBinding struct {
	ProjectPath string    `json:"project_path"`
	ExecutorID  string    `json:"executor_id"`
	BoundAt     time.Time `json:"bound_at"`
	BoundBy     string    `json:"bound_by,omitempty"`
}

ProjectExecutorBinding pins one project directory to one executor.

type ProviderCallRow

type ProviderCallRow struct {
	ID             int64
	Timestamp      time.Time
	Provider       string
	Model          string
	TaskID         int
	TaskTitle      string
	RequestID      string
	Prompt         string
	SystemPrompt   string
	Response       string
	ErrorMessage   string
	Status         string // "ok" | "error" | "timeout" | "context_canceled"
	Headers        string // JSON object, secrets already redacted
	InputTokens    int
	OutputTokens   int
	ThinkingTokens int
	LatencyMs      int
}

ProviderCallRow is one row in the provider_calls table. Headers is a JSON-encoded string by the time it reaches this layer (the redaction step runs in pkg/provideraudit before the row is built).

type QuotaCounterRow

type QuotaCounterRow struct {
	Identity  string
	Resource  string
	Bucket    string
	Value     float64
	UpdatedAt time.Time
}

QuotaCounterRow is one row of quota_counters.

type QuotaOverrideRow

type QuotaOverrideRow struct {
	Identity   string
	LimitsJSON string
	UpdatedAt  time.Time
	UpdatedBy  string
}

QuotaOverrideRow is one row of quota_overrides. LimitsJSON is stored opaquely: the set of resources is pkg/quota's business, and a storage layer that validated it would be a second place to update when one is added.

type RotationRow

type RotationRow struct {
	ID         string
	ToKeyID    string
	State      string
	StartedAt  string
	UpdatedAt  string
	FinishedAt string
	StartedBy  string
	Total      int
	Rewrapped  int
	Skipped    int
	Failed     int
	LastError  string
}

RotationRow is one row of key_rotations.

type SchemaStamp

type SchemaStamp struct {
	Version   int       `json:"version"`
	Name      string    `json:"name,omitempty"`
	AppliedAt time.Time `json:"applied_at,omitempty"`
	AppliedBy string    `json:"applied_by,omitempty"`
}

SchemaStamp is one schema_migrations row: which version was applied, when, and by which build.

AppliedBy is "" for rows written before this provenance existed, and for rows written by a process that never called SetBinaryVersion. Absence is not an error — it just means the refusal message cannot name a build.

type SchemaTooNewError

type SchemaTooNewError struct {
	// DBVersion is the highest version recorded in schema_migrations.
	DBVersion int
	// BinaryVersion is the highest migration embedded in this binary.
	BinaryVersion int
	// Stamp describes the schema_migrations row for DBVersion, when one could
	// be read. A zero Stamp means the row was unreadable, not that it is
	// absent — either way the message simply omits the provenance.
	Stamp SchemaStamp
}

SchemaTooNewError is the detail behind ErrSchemaTooNew: the two versions that disagree, plus whatever the database records about the build that moved it forward.

It satisfies errors.Is for both ErrSchemaTooNew and ErrSchemaMismatch. The second is deliberate: every existing caller that already treats a schema mismatch as fatal (HTTPStatus maps it to 500, classifyDriverErr declines to re-wrap it) keeps working unchanged, while callers that care about this specific cause can ask for it by name.

func (*SchemaTooNewError) Error

func (e *SchemaTooNewError) Error() string

func (*SchemaTooNewError) Is

func (e *SchemaTooNewError) Is(target error) bool

Is makes both sentinels match. See the type comment for why ErrSchemaMismatch is included.

type SealedRow

type SealedRow struct {
	ID         string
	KeyID      string
	WrappedDEK []byte
	Ciphertext []byte
}

SealedRow is a row of sealed material as the rotator sees it: an ID, the associated data it is bound to, and the three envelope parts.

type SecretAuditInput

type SecretAuditInput struct {
	Actor     string
	EventType string // "secret.lease", "secret.grant", "secret.revoke", ...
	EntityID  string // secret ID, or grant ID when no secret is involved
	Timestamp time.Time
	// Payload is the decision's metadata. Callers are responsible for
	// redacting it; pkg/secretbroker.Redact runs over every event before it
	// reaches here, and again inside the secretstore auditor.
	Payload map[string]any
}

SecretAuditInput carries one secret-broker decision to the audit log (Task 20159). It exists so pkg/secretstore does not have to construct an AuditEvent — and, more to the point, so the entity_type is set in exactly one place and every broker row is filterable as entity_type='secret'.

type SecretLeaseDirRow

type SecretLeaseDirRow struct {
	// Dir is the absolute path of the lease directory and the primary key.
	Dir string
	// LeaseID is the lease whose material lives there.
	LeaseID string
	// ExecutorID is the executor the lease was issued to, ” when unbound.
	ExecutorID string
	// ProjectPath is the project the work belongs to, ” when not scoped.
	ProjectPath string
	// CreatedAt is when the intent was recorded — before the directory was
	// created, not after.
	CreatedAt time.Time
	// ExpiresAt is the lease's expiry; the zero time means unbounded. The
	// sweep does not need it to decide, but an operator reading the audit
	// event for a swept directory needs to know whether it had already lapsed.
	ExpiresAt time.Time
}

SecretLeaseDirRow is one credential directory this hub has committed to creating. It records where plaintext is, never what it is.

type SessionRow

type SessionRow struct {
	ID            string
	Subject       string
	Issuer        string
	Email         string
	DisplayName   string
	OwnerKey      string
	Groups        []string
	Roles         []string
	IP            string
	UserAgent     string
	IssuedAt      time.Time
	LastSeen      time.Time
	ExpiresAt     time.Time
	RefreshSealed []byte
	// RefreshKeyID and RefreshWrappedDEK are the envelope this row's sealed
	// refresh token belongs to (Task 20181). "legacy" means the token is
	// sealed directly under the passphrase-derived key, with no wrapped DEK.
	RefreshKeyID      string
	RefreshWrappedDEK []byte
	RefreshCheckedAt  time.Time
}

SessionRow is one row of the sessions table.

RefreshSealed is ciphertext. Nothing in this package encrypts or decrypts it — the sealing happens in pkg/sessionstore, so the storage layer never holds a live credential and cannot leak one by logging a row.

type SizeStats

type SizeStats struct {
	PageCount     int64
	PageSize      int64
	FreelistPages int64
	Bytes         int64
}

SizeStats captures the physical size of the SQLite database at a point in time. Bytes is derived as PageCount * PageSize for convenience; FreelistPages counts free pages that VACUUM would reclaim.

func (SizeStats) FreelistBytes

func (s SizeStats) FreelistBytes() int64

FreelistBytes returns the bytes currently allocated to the freelist — roughly the upper bound on what VACUUM can reclaim.

type State

type State struct {
	Goal        string
	WorkDir     string
	MaxSteps    int
	CurrentStep int
	Status      string
	Steps       []StepRow
	// StepCount mirrors len(Steps) on full loads but is also populated by
	// LoadStateLite — where Steps is nil — so callers that only need a
	// count don't have to materialize every row's Output (Task 20125).
	StepCount int
	// LastStepTime is the timestamp of the newest step row. Populated by
	// both LoadState (last element of Steps) and LoadStateLite (cheap
	// lookup of the highest step number); used by health probes to detect
	// stalled runs without scanning the full Steps slice.
	LastStepTime      time.Time
	CreatedAt         time.Time
	UpdatedAt         time.Time
	Model             string
	Instructions      string
	AutoEvolve        bool
	EvolveStep        int
	Provider          string
	Effort            string
	PMMode            bool
	Plan              *pm.Plan
	Milestones        []*milestone.Milestone
	TotalInputTokens  int
	TotalOutputTokens int
	DefaultMaxMinutes int
	SkipClarify       bool
	InnovateMode      bool
	Parallel          bool
	MaxParallel       int
	WorktreeParallel  bool
	PlanOnly          bool
	RetryFailed       bool
	DryRun            bool
}

State mirrors pkg/state.ProjectState but is owned by this package to avoid import cycles. pkg/state converts between the two representations.

type StepRow

type StepRow struct {
	Step         int
	Task         string
	Output       string
	ExitCode     int
	Duration     string
	Time         time.Time
	InputTokens  int
	OutputTokens int
}

StepRow represents one recorded step result.

type Store

type Store interface {

	// SaveState atomically persists the full project state (metadata, tasks,
	// steps). Existing tasks are replaced; steps are upserted (never deleted).
	SaveState(s *State) error

	// LoadState reads back the complete project state. Returns a zero-value
	// State (not an error) when the database exists but has no rows yet.
	LoadState() (*State, error)

	// UpsertTask inserts or replaces a single task row. Useful for
	// status-only updates without a full SaveState round-trip.
	UpsertTask(t *pm.Task) error

	// LoadTask returns a single task by id. Returns ErrTaskNotFound when
	// the row does not exist; callers should test with errors.Is.
	LoadTask(id int) (*pm.Task, error)

	// AppendStep inserts a step row. Idempotent on step number (upsert).
	AppendStep(row StepRow) error

	// AppendCost appends a single cost entry for the current project.
	AppendCost(entry CostEntry) error

	// ReadCosts returns all cost entries in chronological order.
	ReadCosts() ([]CostEntry, error)

	// ReadCostsSince returns cost entries with timestamp >= since.
	ReadCostsSince(since time.Time) ([]CostEntry, error)

	// MonthlyCosts returns cost entries for the given UTC year/month.
	MonthlyCosts(year, month int) ([]CostEntry, error)

	// PingContext verifies the underlying store is reachable by issuing a
	// trivial query. Used by readiness probes; ctx bounds wait time on a
	// busy connection.
	PingContext(ctx context.Context) error

	// Close releases the underlying connection.
	Close() error
}

Store is the abstract data-access interface for cloop project state. All methods must be safe for concurrent use.

SQLite implementation: DB (this package). PostgreSQL: implement this interface with a pgx/pgxpool-backed struct.

type WorkspaceAuditInput

type WorkspaceAuditInput struct {
	// Phase is "start" or "end", matching executor.WorkspaceProvisionStart /
	// WorkspaceProvisionEnd. It becomes the suffix of the event type, so the
	// family is filterable as event_type LIKE 'workspace.%' and each phase
	// individually by exact match. Anything else is dropped rather than
	// written, so a typo cannot create a third, unqueryable event family.
	Phase string

	// ProjectPath is the project whose tree was being materialised. It is the
	// entity the row is filed under, matching how image denials are filed.
	ProjectPath string

	// ExecutorID and ExecutorKind name the backend doing the fetch. Both are
	// recorded because an ID is stable but says nothing about *where* the code
	// landed, and "was this on a Pod or on someone's laptop" is the first
	// question asked of a provisioning row.
	ExecutorID   string
	ExecutorKind string

	// HandleID correlates the two phases with each other and with the run.
	HandleID string

	// Kind, Repo, Ref and Depth are the workspace as dispatched — the clone
	// URL exactly as the executor was told to fetch it, which is the only form
	// that can be compared against what the operator believes is configured.
	Kind  string
	Repo  string
	Ref   string
	Depth int

	// GrantID and LeaseID name the authority used. They are the join key back
	// into the secret-broker rows, which is what turns "this repo was fetched"
	// into "this grant was exercised at this time".
	GrantID string
	LeaseID string

	// DurationMS is set on the end phase only.
	DurationMS int64

	// Err is the failure, already redacted by the driver, or "" on success.
	Err string

	// Actor is the acting identity. Empty becomes "system": provisioning is
	// decided by the dispatching control plane, and on the failover path there
	// is no human to attribute it to.
	Actor string
}

WorkspaceAuditInput carries one workspace-provisioning observation to the audit log (Task 20179).

Provisioning earns its own rows rather than being folded into the run's, because it is the moment a brokered credential is used against an external service on behalf of a project. "Which grant fetched which repository onto which executor, and did it work" is the question an operator has after an incident, and the run's own record cannot answer it: a run looks identical whether the tree arrived or not, which is the entire reason the workspace subsystem exists.

No field here can carry a credential. The Workspace the executor dispatched is structurally incapable of holding one — pkg/executor.Workspace has no field a token could go in, by design, because a Spec is persisted, logged and shipped across the remote-executor boundary — and GrantID/LeaseID are identifiers, not material. The token never enters this package's scope.

Jump to

Keyboard shortcuts

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