storage

package
v0.14.0 Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: GPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package storage is the persistence layer for Glyphoxa: goose-driven schema migrations (ADR-0031) plus a thin pgx query layer over the core tables.

Query layer choice: pgx/v5 directly, not sqlc. The read surface this task needs is small (load an Agent + its Persona/Voice + bound Provider Configs), so hand-written queries are clearer than adding a codegen step to CI. goose needs a database/sql handle (see migrate.go); the app uses a *pgxpool.Pool.

Index

Constants

View Source
const (
	// HighlightCandidate: freshly detected, subject to the 7-day purge.
	HighlightCandidate = "candidate"
	// HighlightPromoted: an explicit GM keep; never purged.
	HighlightPromoted = "promoted"
)

Highlight status values (CHECK-constrained in the schema).

View Source
const (
	// SoundKindSting: a short sound-effect sting (the ElevenLabs
	// sound-effects endpoint, bounded to ≤22s).
	SoundKindSting = "sting"
	// SoundKindMusic: a composed Music track (the ElevenLabs Music endpoint).
	SoundKindMusic = "music"
)

Highlight sound kinds (CHECK-constrained in the schema, #312): the GM's standing "Add sound" choice on a promoted Highlight. Empty means none requested (the default).

View Source
const (
	PlanningRoleUser      = "user"
	PlanningRoleAssistant = "assistant"
)

The two planning message roles. The CHECK constraint in 00053 mirrors them.

View Source
const DefaultJobMaxAttempts = 5

DefaultJobMaxAttempts is the retry budget applied when a job is enqueued with a non-positive maxAttempts. After this many attempts a failing job goes to JobDead rather than retrying forever (ADR-0049 dead-letter policy).

View Source
const DevOperatorDiscordID = "glyphoxa-dev-operator"

DevOperatorDiscordID is the synthetic Discord identity the GLYPHOXA_DEV_MODE boot upserts as the dev operator (ADR-0041). It is deliberately NOT a real snowflake so it can never collide with a genuine Discord user. It lives here because ResolveOperatorTenant treats a tenant bound to it as still claimable: the first REAL operator login takes the tenant (and everything configured in dev mode) over, instead of being stranded next to it in a fresh empty tenant.

View Source
const MaxAgentFactNodes = kgFactsCap

MaxAgentFactNodes is [kgFactsCap], exported so the GM-facing preview (#535) can tell the GM that the SQL read itself clipped their NPC's neighbourhood. Without it, a hub NPC with more than this many neighbours would show the extras as "not adjacent" — indistinguishable from a missing Edge, and unfixable because the GM would be looking for the wrong problem.

View Source
const MaxAppearances = 50

MaxAppearances caps one entry's Appearances list. A long-running campaign mentions its central NPC in hundreds of lines, and "the last N times" is the question; an unbounded list is a slow read nobody scrolls.

View Source
const MaxBoardNameRunes = 120

MaxBoardNameRunes bounds a board name. Tags were capped and board names were not, so a board could carry a megabyte of text that every ListBoards response then had to return.

View Source
const MaxTagRunes = 40

MaxTagRunes bounds one tag. A tag is a label, not a sentence.

View Source
const MaxTagsPerNode = 20

MaxTagsPerNode bounds how many an entry may carry — enough for real organization, few enough that the chip row stays readable.

View Source
const VoiceSessionReasonOrphaned = "orphaned: reconciled at startup"

VoiceSessionReasonOrphaned is the end_reason stamped by the boot-time reconciliation (#143): the row was still 'running' but no live loop owned it (crash / kill -9 / a failed end-write), so startup closed it. A NULL end_reason means the session ended through the normal Stop / loop-exit path.

Variables

View Source
var ErrAspectsFull = errors.New("storage: node has reached its aspect limit")

ErrAspectsFull is returned by the approve path when a Node already carries kgvocab.MaxAspectsPerNode Aspects. It is a refusal, not a failure: silently exceeding the cap would leave the entry permanently unsaveable in the editor, which validates against the same cap — the GM would be unable to fix even a typo without first deleting rows they never chose to add.

View Source
var ErrButlerUndeletable = errors.New("storage: butler cannot be deleted")

ErrButlerUndeletable is returned by DeleteAgent when the target Agent is a Butler. The Butler is an invariant of a Campaign's existence (ADR-0009): it is auto-created and cannot be removed. The RPC layer maps this to Connect CodeFailedPrecondition.

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

ErrConflict is returned when a write hits a UNIQUE constraint (Postgres 23505): a duplicate (from, to, type) Edge, or an Agent already linked to another Node. The RPC layer maps it to CodeAlreadyExists.

View Source
var ErrGuildTaken = errors.New("storage: this guild is already bound by another tenant")

ErrGuildTaken is returned by SaveDiscordChannels when the guild_id is already bound by a DIFFERENT Tenant — the first-registrar-wins unique index (#483; full guild-permission proof is #504). The RPC layer maps it to CodeFailedPrecondition so the save is refused instead of silently rebinding another Tenant's guild (the old newest-wins read let the rebinder read the victim's voice-channel members and hijack its command routing).

View Source
var ErrIntentActive = errors.New("storage: a voice session intent is already active for this tenant")

ErrIntentActive is returned by CreateVoiceSessionIntent when the Tenant already has a non-terminal (pending/claimed/live) intent — the one-live-per-tenant partial UNIQUE index (23505). The RPC layer maps it to CodeAlreadyExists, mirroring session.ErrSessionActive.

View Source
var ErrInvalidDisposition = errors.New("storage: disposition must be between -2 and +2")

ErrInvalidDisposition is returned when a disposition falls outside -2..+2.

View Source
var ErrInvalidEdge = errors.New("storage: invalid edge")

ErrInvalidEdge is returned when an Edge's (type, from-type, to-type) combination violates the amendment's validity matrix, or when an Agent link targets a non-NPC Node (the DB CHECK). The RPC layer maps it to CodeInvalidArgument.

View Source
var ErrInvalidMapParent = errors.New("storage: a map cannot contain itself")

ErrInvalidMapParent is returned when a re-parent would make a Map its own ancestor.

View Source
var ErrInvalidPin = errors.New("storage: pin coordinates must be within 0..1")

ErrInvalidPin is returned when a Pin's coordinates fall outside the normalized 0..1 range the schema enforces.

View Source
var ErrInvalidTag = errors.New("storage: invalid tag")

ErrInvalidTag is returned for a blank or over-long tag.

View Source
var ErrNotArchived = errors.New("storage: campaign not archived")

ErrNotArchived is returned by DeleteCampaign when the target campaign exists but is not archived: the hard delete is refused until the campaign has been archived first (#269, decided on #265). The RPC layer maps it to Connect CodeFailedPrecondition.

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

ErrNotFound is returned when a query matches no row.

View Source
var ErrNoteTooLong = errors.New("storage: edge note is too long")

ErrNoteTooLong is returned when an Edge note exceeds kgvocab.MaxEdgeNoteRunes.

View Source
var ErrPlanArchived = errors.New("storage: plan is archived")

ErrPlanArchived is returned by SetTenantPlan for an archived plan slug: an archived tier accepts no NEW subscriptions (existing ones keep running).

View Source
var ErrUserSuspended = errors.New("storage: user is suspended")

ErrUserSuspended reports a signup/login attempt by a suspended user (users.suspended_at set, ADR-0055 open-mode revocation). The OAuth callback bounces these at the door instead of minting a session the per-request re-check would refuse anyway.

Functions

func BuildTSQuery

func BuildTSQuery(q string) string

BuildTSQuery turns a raw GM query string into a safe to_tsquery('simple') input. tsquery operator characters (& | ! ( ) : * …) are never passed through as operators: every non-letter/non-digit rune is a term separator, so a malicious or accidental operator can only ever split words. Surviving terms AND-join, and only the LAST term gets the ":*" prefix marker (typeahead — the word the GM is still typing). Returns "" when nothing survives, which SearchNodes treats as a no-op (no matches, not an error).

func EnsureCurrent

func EnsureCurrent(ctx context.Context, db *sql.DB) error

EnsureCurrent verifies the DB schema is at the latest known version. web and voice Modes call this at startup and fail fast if the schema is behind (ADR-0031) — they never auto-migrate.

func GrantsFromRows

func GrantsFromRows(rows []ToolGrant) []tool.Grant

GrantsFromRows maps an Agent's persisted Tool Grant rows to the in-memory [tool.Grant]s the live loop hydrates into a tool.GrantSet (#113, ADR-0029) — the identical shape the orchestrator consumes, so the loop never knows the grants came from the DB. A row's jsonb config becomes Grant.Config as a json.RawMessage (nil when the column is NULL — dice's shape); the Tool handler receives it as grantConfig at execution time and enforces scope there, never the LLM. No rows yields no grants: the Agent is shown no Tool at all.

This is the SINGLE canonical row→Grant mapping. wirenpc's live loop and the grant RPC's AC4 hydration test both call it, so neither can drift from the other (issue #215). It is the one place in this package that depends on pkg/tool — the vendor-neutral CRUD in tool_grant.go keeps the storage rows a raw blob; only this shared mapper crosses into the Tool domain, and the dependency is one-way (pkg/tool never imports internal/*).

func MigrateDown

func MigrateDown(ctx context.Context, db *sql.DB) error

MigrateDown rolls back the most recently applied migration (locked).

func MigrateUp

func MigrateUp(ctx context.Context, db *sql.DB) error

MigrateUp applies all pending migrations (locked). Called at startup in `all` Mode (ADR-0031); web/voice Modes do not auto-migrate.

func NewMigrationProvider

func NewMigrationProvider(db *sql.DB) (*goose.Provider, error)

NewMigrationProvider builds the goose Provider over the embedded migrations, configured with the Postgres session locker required by ADR-0031.

db must be a *sql.DB on a Postgres driver (e.g. pgx/v5/stdlib). goose needs a database/sql handle; the application's own queries use a pgxpool separately.

func ValidateEdge

func ValidateEdge(t KGEdgeType, from, to KGNodeType) error

ValidateEdge enforces the ADR-0008 amendment's object-side-only validity matrix. Structural edge types constrain their target (resides_in → Location, member_of → Faction, participated_in → PlotThread); parent_of constrains both ends to Character/NPC. The subject side of the structural types and every social/loose type (knows, owns, enemy_of, ally_of, mentioned_in) accept any Node type — the domain legitimately contains sentient swords that know kings. It is pure (no DB): the create path validates before the INSERT.

func Version

func Version(ctx context.Context, db *sql.DB) (int64, error)

Version returns the current (highest applied) schema version, 0 if none.

func VoiceFromJSON

func VoiceFromJSON(raw json.RawMessage) (tts.Voice, error)

VoiceFromJSON decodes an Agent's persisted voice JSONB into a tts.Voice. It is the SINGLE canonical reader for the voice column, mirroring GrantsFromRows (#215): the voice pipeline's hydration and the Campaign RPC's editor mapping both go through it, so a writer and a reader can never drift into the silent NPC of issue #224.

The CANONICAL SHAPE is the Go-default field names of tts.Voice ({"ProviderID","VoiceID","Name","Language","Settings"}) — the shape the seed rows already hold and the pipeline already reads. tts.Voice deliberately carries NO json tags (ADR-0022 keeps Settings opaque and the type untouched); tagging it would orphan every healthy row.

An empty column or a bare {} — the schema default and the editor's "no voice" — decodes to the zero Voice with a nil error. A Settings persisted as the JSON literal null normalizes to a nil Settings so a settings-less Voice round-trips identically. A genuinely unparsable blob is an error, never a silent zero.

func VoiceToJSON

func VoiceToJSON(v tts.Voice) (json.RawMessage, error)

VoiceToJSON encodes a tts.Voice into the canonical voice JSONB VoiceFromJSON reads back — the write counterpart, so both directions share one shape.

Types

type Agent

type Agent struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	Role       AgentRole
	Name       string
	// Title is the Agent's role subtitle shown in the editor (e.g. "Gruff
	// innkeeper"); free text, may be empty.
	Title string
	// Persona: markdown personality/backstory/speech style.
	Persona string
	// Voice (ADR-0022/0023): TTS provider + voice-id config, stored as JSONB.
	Voice json.RawMessage
	// VoiceProviderConfigID is the TTS Provider Config backing this Agent's Voice.
	VoiceProviderConfigID uuid.NullUUID
	// LLMProviderConfigID is the LLM Provider Config this Agent reasons with.
	// May be null; resolving a tenant default when null is a #6 concern (the
	// schema has no is_default marker yet, so no fallback is wired here).
	LLMProviderConfigID uuid.NullUUID
	// ChatLLMProviderConfigID is the Butler's chat-scoped LLM slot (ADR-0062):
	// the planning chat resolves it FIRST, so upgrading chat quality never
	// touches the voice loop's LLMProviderConfigID. May be null (the ladder
	// falls back to the tenant 'chat_llm' then 'llm' Provider Config). Written
	// by no editor surface in v1 — UpdateAgent deliberately leaves the column
	// alone, mirroring how it preserves fields the editor never sees.
	ChatLLMProviderConfigID uuid.NullUUID
	// AddressOnly: reachable only by explicit name/alias (ADR-0024). Butler true.
	AddressOnly bool
	// SpeakerColor is a server-assigned palette SLOT (not a colour value): the web
	// tier maps it onto its speaker palette so each roster member renders in a
	// stable hue across reloads (#71). Assigned round-robin per Campaign on
	// Character insert; the Butler keeps slot 0.
	SpeakerColor int
	Aliases      []string
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

Agent is an AI-controlled persona — Butler or Character NPC (ADR-0009).

type AgentLastSpoke added in v0.5.0

type AgentLastSpoke struct {
	Who string
	At  time.Time
}

AgentLastSpoke is one Agent's most recent committed Transcript Line (#544). Who is the Line's speaker label, which for an Agent turn is its display NAME — transcript_line carries no agent_id (ADR-0040 persists what was said and by whom-as-shown, not a foreign key), so the roster matches on name. A renamed Agent therefore loses its history here; that is a display nicety on a prep dashboard, not a correctness claim, and inventing a join would mean changing what the Line grain records.

type AgentRole

type AgentRole string

AgentRole is an Agent's archetype (ADR-0009): the agents table is polymorphic over this enum so one orchestrator/address-detection path serves both.

const (
	AgentRoleButler    AgentRole = "butler"
	AgentRoleCharacter AgentRole = "character"
)

type AgentUpdate

type AgentUpdate struct {
	ID                    uuid.UUID
	CampaignID            uuid.UUID
	Name                  string
	Title                 string
	Persona               string
	Voice                 []byte // JSON; defaults to {} when nil
	VoiceProviderConfigID uuid.NullUUID
	LLMProviderConfigID   uuid.NullUUID
	AddressOnly           bool
	Aliases               []string
}

AgentUpdate is the input to UpdateAgent. It carries the editor-editable fields only — the Campaign screen edits name/title/persona/voice/address-only/aliases; agent_role and speaker_color are immutable here. CampaignID is the owning Campaign the write is scoped to (#342): the UPDATE matches (id, campaign_id), so an Agent in another Campaign is invisible and yields ErrNotFound — cross-campaign mutation is refused, and an Agent never moves between Campaigns. A Butler's address_only is force-kept true regardless of AddressOnly (ADR-0009 / ADR-0024).

type AppearanceHit added in v0.5.0

type AppearanceHit struct {
	VoiceSessionID uuid.UUID
	LineID         string
	At             time.Time
	Who            string
	Kind           string
	Text           string
	// SessionStartedAt lets the UI label which session a mention came from without
	// a second read.
	SessionStartedAt time.Time
}

AppearanceHit is one appearance joined to the line that produced it — what the entry editor's Appearances list renders and deep-links from.

type Campaign

type Campaign struct {
	ID       uuid.UUID
	TenantID uuid.UUID
	// GMMemberID references a Member (Member Role 'gm'). The members table is
	// task #6, so this is a bare nullable UUID for now (SEAM #6).
	GMMemberID uuid.NullUUID
	Name       string
	System     string
	Language   string
	CreatedAt  time.Time
	UpdatedAt  time.Time
	// ArchivedAt is when the campaign was archived (#269), or nil when active.
	// Archived campaigns are excluded from ListCampaigns, the /glyphoxa use
	// autocomplete, and the GetActiveCampaign most-recent fallback, and cannot back
	// a Voice Session.
	ArchivedAt *time.Time
	// TapeArmed is the GM opt-in that arms the rollover tape for this Campaign's
	// Voice Sessions (#306, ADR-0051; default false, capture hard-disabled without
	// it). Appended LAST in campaignColumns/scanCampaign (column-order coupling), so
	// any new column follows it in both places.
	TapeArmed bool
}

Campaign is a persistent TTRPG game owned by a Tenant and GM'd by one Member.

type CampaignMap added in v0.5.0

type CampaignMap struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	Name       string
	BlobKey    string
	WidthPx    int
	HeightPx   int
	// ParentMapID is the Map this one sits inside; AnchorNodeID is the Location
	// Node this Map DEPICTS. Together they give the continent → region → city →
	// building hierarchy GMs actually draw, and let a pin on one map open the map
	// beneath it.
	ParentMapID  uuid.NullUUID
	AnchorNodeID uuid.NullUUID
	GMPrivate    bool
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

CampaignMap is one persisted Map. BlobKey addresses the image in the blob seam; WidthPx/HeightPx are the source dimensions, kept for aspect-ratio rendering and deliberately NOT used for coordinate maths.

type CampaignMapUpdate added in v0.5.0

type CampaignMapUpdate struct {
	ID           uuid.UUID
	CampaignID   uuid.UUID
	Name         string
	ParentMapID  uuid.NullUUID
	AnchorNodeID uuid.NullUUID
	GMPrivate    bool
}

CampaignMapUpdate is the Map editor's field set. blob_key and dimensions are updated separately by the re-upload path, which is what keeps a rescale from touching pins.

type CampaignUpdate

type CampaignUpdate struct {
	// TenantID is the owning Tenant the write is scoped to (#473): the UPDATE matches
	// (id, tenant_id), so a campaign in another Tenant is invisible and yields
	// ErrNotFound — a cross-tenant rename is refused, never a permission error that
	// would confirm the id exists. The RPC handler fills it from auth.TenantID(ctx).
	TenantID uuid.UUID
	ID       uuid.UUID
	Name     string
	System   string
	Language string
	// TapeArmed, when non-nil, sets the rollover-tape opt-in (#306, ADR-0051); nil
	// leaves the current value unchanged (the proto field is `optional`, so an
	// UpdateCampaign that does not touch the tape must not silently disarm it).
	TapeArmed *bool
}

CampaignUpdate is the input to UpdateCampaign. It carries the operator-editable fields only — name/system/language; tenant_id, gm_member_id and the timestamps are not settable here. System/Language are written verbatim as opaque free-text strings (no validation at this layer), mirroring how they are stored on create.

type Character

type Character struct {
	ID            uuid.UUID
	CampaignID    uuid.UUID
	Name          string
	Aliases       []string
	DiscordUserID string
	// LinkedUserID is nil until the Player first signs in via Discord OAuth
	// (ADR-0003); it never becomes NULL-mandatory like discord_user_id.
	LinkedUserID *string
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

Character is one persisted Player Character in a Campaign.

type CharacterUpdate

type CharacterUpdate struct {
	ID            uuid.UUID
	CampaignID    uuid.UUID
	Name          string
	Aliases       []string
	DiscordUserID string
}

CharacterUpdate is the input to UpdateCharacter — a full-field save of the editor fields. DiscordUserID is included so an operator can rebind a Character to a different Discord User (it stays NOT NULL). CampaignID is the owning Campaign the write is scoped to (#342): the UPDATE matches (id, campaign_id), so a row in another Campaign is invisible and yields ErrNotFound — a Character never moves between Campaigns, and no operator can mutate one they do not own.

type ChunkMatch

type ChunkMatch struct {
	Chunk    TranscriptChunk
	Distance float64
}

ChunkMatch is one Transcript Chunk returned by ANN retrieval together with its cosine distance to the query vector (#119, ADR-0011). Distance comes from pgvector's <=> operator: smaller = nearer, ascending order = nearest first.

type Component

type Component string

Component is a Provider category a Provider Config binds to (ADR-0004).

const (
	ComponentLLM        Component = "llm"
	ComponentSTT        Component = "stt"
	ComponentTTS        Component = "tts"
	ComponentEmbeddings Component = "embeddings"
	ComponentS2S        Component = "s2s"
	// ComponentImage is AI image generation (#311, Epic 8, ADR-0004 amendment):
	// the enum value the 00028 migration adds. Gemini is its v1 provider.
	ComponentImage Component = "image"
	// ComponentChatLLM is the tenant-level chat-scoped LLM slot (#592,
	// ADR-0062): the Butler planning chat's resolution ladder reads it between
	// the Butler's own chat slot and the voice 'llm' config, so chat can run a
	// quality-over-latency model without re-pricing the live voice loop. The
	// 00053 migration adds the enum value.
	ComponentChatLLM Component = "chat_llm"
)

type DeploymentConfig

type DeploymentConfig struct {
	TenantID                  uuid.UUID
	DiscordBotTokenCiphertext []byte
	DiscordBotTokenLast4      string
	GuildID                   string
	VoiceChannelID            string
	CreatedAt                 time.Time
	UpdatedAt                 time.Time
}

DeploymentConfig is the single-operator Discord integration the Configuration screen edits (#68): the deployment Bot token — a write-only secret, sealed at rest like a Provider Config — plus the non-secret Guild / Voice channel IDs. The Bot is deployment-shared (one token regardless of Tenant, CONTEXT.md), so this is distinct from the per-Component, Tenant-scoped provider_config (ADR-0004); it is keyed by tenant_id only for the MVP single operator (ADR-0039). DiscordBotTokenCiphertext is empty until a token is saved.

type ExportChunk

type ExportChunk struct {
	TranscriptChunk
	Embedding string
}

ExportChunk is one Transcript Chunk plus its embedding rendered as pgvector's text form ("[...]") for the Campaign Bundle exporter (#288, ADR-0053). Embedding is "" when the row's vector is NULL or when the caller excluded vectors — the default export strips embeddings (ADR-0053 d3), so the destination re-embeds.

type GrantSurface added in v0.8.0

type GrantSurface string

GrantSurface is the Agent surface a Tool Grant arms (#592, ADR-0062): the live voice loop and the Butler planning chat hydrate DISJOINT grant rows, so the two surfaces cannot leak tools into each other.

const (
	// GrantSurfaceVoice arms the live voice loop — the pre-0062 meaning of
	// every grant row, and the column default.
	GrantSurfaceVoice GrantSurface = "voice"
	// GrantSurfaceChat arms the Butler planning chat's tool belt (ADR-0062).
	GrantSurfaceChat GrantSurface = "chat"
)

type Highlight

type Highlight struct {
	ID              uuid.UUID
	TenantID        uuid.UUID
	VoiceSessionID  uuid.UUID
	CampaignID      uuid.UUID
	Status          string
	StartsAt        time.Time
	EndsAt          time.Time
	Score           float64
	Excerpt         string
	Reason          string
	SpeakerIDs      []string
	ClipKey         string
	ClipContentType string
	ClipSizeBytes   int64
	// ImageKey / ImageContentType / ImageSizeBytes carry the AI-generated scene
	// (#311, Epic 8, ADR-0004 amendment): the enrichment job stores an image
	// behind the blob seam (ADR-0048) and lands it here. ImageKey == "" means no
	// image yet (unenriched, unconfigured, or a failed generation — the row stays
	// intact without media). ImageKey is NEVER exposed on the wire (clip_key
	// posture): the image is served through GET /highlights/{id}/image.
	ImageKey         string
	ImageContentType string
	ImageSizeBytes   int64
	// SoundKind / SoundRequestedAt / SoundKey / SoundContentType /
	// SoundSizeBytes carry the GM's opt-in sound enrichment (#312, Epic 8,
	// ADR-0004 amendment): SoundKind is the standing choice ("sting" or
	// "music", "" = none), SoundRequestedAt stamps the latest request (the web
	// tier's await-media poll bound), and the key/content-type/size triad
	// mirrors the image's. SoundKey == "" with SoundKind != "" means requested
	// but not landed (generating, unconfigured, or failed — the row stays
	// intact without media). SoundKey is NEVER exposed on the wire (clip_key
	// posture): the audio is served through GET /highlights/{id}/sound.
	SoundKind        string
	SoundRequestedAt *time.Time
	SoundKey         string
	SoundContentType string
	SoundSizeBytes   int64
	CreatedAt        time.Time
	PromotedAt       *time.Time
}

Highlight is one persisted Session Highlight row (#308).

type HighlightEnrichTarget added in v0.2.1

type HighlightEnrichTarget struct {
	HighlightID uuid.UUID
	TenantID    uuid.UUID
}

HighlightEnrichTarget is a promoted, still-imageless Highlight the boot reconciliation sweep must (re)enqueue image enrichment for (#406): the id plus the tenant that owns it, exactly the enrich job payload's two fields (the sweep carries no ambient tenant — it is process-wide, ADR-0049).

type HighlightSoundEnrichTarget added in v0.10.0

type HighlightSoundEnrichTarget struct {
	HighlightID uuid.UUID
	TenantID    uuid.UUID
	Kind        string
}

HighlightSoundEnrichTarget is a promoted Highlight whose requested sound never landed and has no live generation job — the boot reconciliation sweep's re-enqueue input (#312, the #406 pattern). Kind rides along because the sound job payload carries the requested kind (unlike the image's).

type IndexableLine added in v0.5.0

type IndexableLine struct {
	LineID string
	At     time.Time
	Text   string
}

IndexableLine is one committed Transcript Line the indexer scans.

type IndexableNode added in v0.5.0

type IndexableNode struct {
	ID   uuid.UUID
	Name string
}

IndexableNode is one Node the indexer matches against: its id and its name, and nothing else. Deliberately no prose — the indexer matches NAMES, and hauling every entry's body across for that would be pure waste.

type Job

type Job struct {
	ID          uuid.UUID
	Kind        string
	Payload     []byte // jsonb; the handler-scoped payload (carries its own scope, no tenant_id column)
	Status      JobStatus
	Attempts    int
	MaxAttempts int
	RunAfter    time.Time
	LeasedUntil *time.Time
	LastError   *string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Job is one row of the background job queue (#286, ADR-0049).

type JobStatus

type JobStatus string

JobStatus is the lifecycle state of a job row (CHECK-constrained in the schema).

const (
	// JobPending: not yet claimed; runnable once run_after <= now().
	JobPending JobStatus = "pending"
	// JobRunning: claimed by a worker, leased_until in the future.
	JobRunning JobStatus = "running"
	// JobDone: handler succeeded; terminal.
	JobDone JobStatus = "done"
	// JobDead: exhausted its attempts (or swept); terminal, last_error kept.
	JobDead JobStatus = "dead"
)

type KGBoard added in v0.5.0

type KGBoard struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	Name       string
	NodeIDs    []uuid.UUID
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

KGBoard is a named, ordered set of entries the GM pins for one session — the "tonight: the harbour heist" list they already keep in a text file outside the tool. NodeIDs is in board order.

type KGEdge

type KGEdge struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	FromNodeID uuid.UUID
	ToNodeID   uuid.UUID
	Type       KGEdgeType
	// Note is the relation's texture — "owes money to", "since the siege" (#546).
	// A bare `knows` carries nothing; this is what makes it a relationship.
	Note string
	// Disposition is how the SUBJECT feels about the object, -2..+2 (hostile,
	// cold, neutral, warm, devoted). Edges are strictly directional with no
	// auto-inverse (ADR-0008 amendment), which is exactly what makes asymmetric
	// feelings expressible: a secretly hostile ally is one edge with a negative
	// disposition and no matching return edge.
	Disposition int
	CreatedAt   time.Time
}

KGEdge is one persisted typed directional Edge between two Nodes in a Campaign.

func (KGEdge) DispositionClause added in v0.5.0

func (e KGEdge) DispositionClause(targetName string) string

DispositionClause renders an Edge's feeling as ONE short clause for the fact block, or "" when there is nothing to say (#546).

Strictly one clause, because it spends the shared MaxBlockChars budget that world facts also draw on — and renderFacts stops at the first fact that would overrun, so a verbose relationship would evict knowledge outright.

type KGEdgeType

type KGEdgeType string

KGEdgeType is a Knowledge Graph Edge's type (CONTEXT.md "Edge", ADR-0008). It mirrors the kg_edge_type Postgres enum. The values are compiler-linked to the single relation vocabulary in pkg/kgvocab (#449), which the remember_knowledge Tool's schema/validation also derives from.

type KGEdgeWithNodes

type KGEdgeWithNodes struct {
	KGEdge
	FromName string
	FromType KGNodeType
	ToName   string
	ToType   KGNodeType
}

KGEdgeWithNodes is an Edge joined to its two endpoints' display fields, so the Campaign screen renders an incident-edge list without an N+1 per endpoint.

type KGGraphNode added in v0.5.0

type KGGraphNode struct {
	ID          uuid.UUID
	Type        KGNodeType
	Name        string
	GMPrivate   bool
	AgentID     uuid.NullUUID
	BodyLen     int
	AspectCount int
	// PublicAspectCount counts only the Aspects a prompt can actually receive.
	// The distinction matters: an entry whose ONLY fact is a GM secret is authored
	// (so AspectCount is 1, and the GM's own map should say so) but says nothing to
	// its NPC (so the health and readiness derivations must read 0, or they report
	// exactly the state they exist to catch as healthy).
	PublicAspectCount int
}

KGGraphNode is one Node as the graph view needs it: identity, type, name and the two flags the rendering distinguishes on — WITHOUT body or aspect text. BodyLen and AspectCount are the "does this entry actually say anything" signal the health panel and the readiness marks derive from; sending the text itself would multiply the payload for something no node glyph renders.

type KGNode

type KGNode struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	Type       KGNodeType
	Name       string
	Body       string
	GMPrivate  bool
	// AgentID is the optional NPC-Node ↔ Character NPC Agent link (#132, ADR-0008
	// amendment): the "voiced by" cast Agent. Only an NPC Node may carry it (DB
	// CHECK); NULL when the Node is wiki-only or not an NPC.
	AgentID   uuid.NullUUID
	CreatedAt time.Time
	UpdatedAt time.Time
	// PortraitBlobKey names the Node's portrait bytes in the blob seam (#590);
	// empty means no portrait. Written only by SetNodePortrait — the editor
	// mutations never touch it, so a save cannot drop a portrait.
	PortraitBlobKey string
	// Aspects is the Node's ordered per-fact visibility layer (#542, ADR-0008 third
	// amendment), populated only by the reads that project it. A PROMPT-FACING read
	// returns public Aspects ONLY — the exclusion lives in the read's SQL, so this
	// field can never carry a GM secret into prompt assembly. nil means "this Node
	// has none" for a projecting read, and "not loaded" for one that does not
	// project them (the RETURNING projections of Create/Update).
	Aspects []KGNodeAspect
	// Relation is how the READING Agent relates to this Node, populated only by
	// [Store.AgentNodeFacts] (#546): the disposition and note on the Agent's own
	// OUTGOING edge to it. Outgoing only, because an Edge is a one-way assertion —
	// how someone else feels about this Node is not this NPC's feeling.
	RelationNote        string
	RelationDisposition int
}

KGNode is one persisted Knowledge Graph Node in a Campaign. GMPrivate hides the Node from any NPC's Hot Context (#126); Body is the Node's prose (empty by default).

func (KGNode) AspectLines added in v0.5.0

func (n KGNode) AspectLines(publicOnly bool) []string

AspectLines renders a Node's Aspects as "Key: Value" lines — the flat form the embedding text consumes, where the key carries real signal about what kind of fact the value is. publicOnly drops gm_private rows.

A row with no value renders as its key alone; a fully empty row is skipped.

func (KGNode) AspectValues added in v0.5.0

func (n KGNode) AspectValues(publicOnly bool) []string

AspectValues returns a Node's Aspect VALUES — without their keys — the granularity the ADR-0052 write-time dedup compares at, because a proposal's salient text is the fact itself and the key is only how it is filed (#411, #542). publicOnly is mandatory for every prompt-reachable consumer: a matched established fact is quoted back to the model in the Tool result, so a private Aspect matched here would leak a GM secret the prompt seam never carried.

type KGNodeAspect added in v0.5.0

type KGNodeAspect struct {
	ID        uuid.UUID `json:"id"`
	Position  int       `json:"position"`
	Key       string    `json:"key"`
	Value     string    `json:"value"`
	GMPrivate bool      `json:"gm_private"`
}

KGNodeAspect is one persisted Aspect of a Node. Position is the author order within its Node (0-based, dense after every ReplaceNodeAspects). GMPrivate hides THIS row — and only this row — from every prompt-facing read.

The json tags are the wire format of the jsonb aggregate the Node reads pack their Aspects into (see kgNodeAspectsExpr), not a stored document: the columns are real columns.

type KGNodeAspectWrite added in v0.5.0

type KGNodeAspectWrite struct {
	Known []uuid.UUID
	Rows  []NewKGNodeAspect
}

KGNodeAspectWrite is one editor save of a Node's Aspects (#542). Rows carries the list the GM authored, in their order, each keeping the id of the row it came from. Known carries the ids the editor had LOADED.

Replace-in-full is the right editor contract (one save covers add, edit, reorder and delete, with no client-side identity bookkeeping), but the naive form — "delete everything, insert the list" — has two failure modes, and the fix for one must not create the other:

  • It silently destroys an Aspect that a Knowledge Proposal approval appended while the editor was open. Known bounds the delete to rows the editor actually saw, so a row it never knew about survives.
  • Deleting and reinserting ROTATES every row's id, which makes a second save from a stale client (a second tab, or a failed background refetch) match nothing and DUPLICATE the entire list. So rows are updated in place by id and ids stay stable across saves; a save that repeats itself is idempotent.

type KGNodePair added in v0.5.0

type KGNodePair struct {
	AID, BID     uuid.UUID
	AName, BName string
	Similarity   float64
}

KGNodePair is two Nodes in one Campaign whose embeddings are close — a PROBABLE-duplicate hint for the world health panel (#536). Similarity is 1 minus pgvector's cosine distance (<=>), so it ranges over [-1, 1]: 1.0 is identical direction, 0 orthogonal, negative opposed. Only the high end is ever surfaced, so the sign never reaches a caller in practice.

type KGNodeType

type KGNodeType string

KGNodeType is a Knowledge Graph Node's type (CONTEXT.md "Node", ADR-0008). It mirrors the kg_node_type Postgres enum; the value is immutable after create. The values are compiler-linked to the single node-type vocabulary in pkg/kgvocab (#449), which the remember_knowledge Tool's schema/validation and the GM-facing label map also derive from.

type KGNodeUpdate

type KGNodeUpdate struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	Name       string
	Body       string
	GMPrivate  bool
}

KGNodeUpdate is the input to UpdateNode — the Knowledge panel's editor fields (#129). It carries no Type (node_type is immutable, ADR-0008). CampaignID is the owning Campaign the write is scoped to (#342): the UPDATE matches (id, campaign_id), so a Node in another Campaign is invisible and yields ErrNotFound — a Node never moves between campaigns, and cross-campaign mutation is refused.

type KnowledgeDraftEdge added in v0.3.0

type KnowledgeDraftEdge struct {
	FromIndex int
	ToIndex   int
	Type      KGEdgeType
}

KnowledgeDraftEdge is one edge of a knowledge draft to apply, referencing the draft's node list by index — the nodes have no ids until the apply lands.

type KnowledgeProposal

type KnowledgeProposal struct {
	ID                 uuid.UUID
	CampaignID         uuid.UUID
	AuthoringAgentID   uuid.UUID
	AuthoringAgentName string
	ProposedWrite      json.RawMessage
	Status             string
	CreatedAt          time.Time
	ReviewedAt         *time.Time
}

KnowledgeProposal is one persisted proposal row. ProposedWrite is the raw jsonb tagged union; ReviewedAt is nil until the GM acts. AuthoringAgentName is the filing Agent's display name, joined from the agents table by the list/get reads (empty on a bare row that skipped the join).

type LoadedAgent

type LoadedAgent struct {
	Agent     Agent
	LLMConfig *ProviderConfig
	TTSConfig *ProviderConfig
}

LoadedAgent is an Agent with its bound Provider Configs resolved — the bundle the orchestrator needs to bring an Agent to life (Persona, Voice, LLM/TTS configs). Either config may be nil if the Agent has none bound.

type MapPin added in v0.5.0

type MapPin struct {
	ID            uuid.UUID
	MapID         uuid.UUID
	CampaignID    uuid.UUID
	NodeID        uuid.UUID
	X, Y          float64
	LabelOverride string
	GMPrivate     bool
	NodeName      string
	NodeType      KGNodeType
	NodeGMPrivate bool
}

MapPin is one persisted Pin: a normalized position on a Map for a KG Node. NodeName and NodeType are joined from kg_node so the Maps tab can draw a pin without a second read; LabelOverride replaces the Node's name on this Map only ("the back door" for an entry called "Rusty Anchor cellar").

func (MapPin) Hidden added in v0.5.0

func (p MapPin) Hidden() bool

Hidden reports whether a Pin must be withheld from a player-tier view (ADR-0056). A Pin is hidden by its OWN flag or by its Node's: a position that points at a GM secret is itself a leak, which mirrors ADR-0008's rule that gm_private filtering applies to neighbour expansion and not only to direct reads.

func (MapPin) Label added in v0.5.0

func (p MapPin) Label() string

Label is what the Pin shows: its override when set, else the Node's name.

type MapPinUpdate added in v0.5.0

type MapPinUpdate struct {
	ID            uuid.UUID
	CampaignID    uuid.UUID
	X, Y          float64
	LabelOverride string
	GMPrivate     bool
}

MapPinUpdate moves or relabels a Pin. Position and presentation are one write because dragging a pin and renaming it are the same act to the GM: adjusting what this Map says about that entry.

type MigrationStatus

type MigrationStatus struct {
	Version int64
	Source  string
	Applied bool
}

MigrationStatus is one migration's applied/pending state, for `migrate status`.

func Status

func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error)

Status returns the state of every known migration, ordered by version.

type NewAgent

type NewAgent struct {
	CampaignID            uuid.UUID
	Role                  AgentRole
	Name                  string
	Title                 string
	Persona               string
	Voice                 []byte // JSON; defaults to {} when nil
	VoiceProviderConfigID uuid.NullUUID
	LLMProviderConfigID   uuid.NullUUID
	AddressOnly           bool
	Aliases               []string
}

NewAgent is the input to CreateAgent. Voice is the opaque JSONB blob the voice domain serializes its tts.Voice into; storage keeps it vendor-neutral. SpeakerColor is NOT an input — it is server-assigned on Character insert.

type NewCampaign

type NewCampaign struct {
	TenantID uuid.UUID
	Name     string
	System   string
	Language string
}

NewCampaign is the input to CreateCampaign. GMMemberID is left zero (the members table is task #6); the column is nullable.

type NewCampaignMap added in v0.5.0

type NewCampaignMap struct {
	CampaignID   uuid.UUID
	Name         string
	BlobKey      string
	WidthPx      int
	HeightPx     int
	ParentMapID  uuid.NullUUID
	AnchorNodeID uuid.NullUUID
	GMPrivate    bool
}

NewCampaignMap is the input to CreateMap. The blob is written FIRST and its key passed here, so a row never references bytes that do not exist.

type NewCharacter

type NewCharacter struct {
	CampaignID    uuid.UUID
	Name          string
	Aliases       []string
	DiscordUserID string
}

NewCharacter is the input to CreateCharacter. LinkedUserID is intentionally absent — a Character is created from its Discord identity and only gains a linked user later, via OAuth.

type NewKGEdge

type NewKGEdge struct {
	CampaignID uuid.UUID
	FromNodeID uuid.UUID
	ToNodeID   uuid.UUID
	Type       KGEdgeType
}

NewKGEdge is the input to CreateEdge. The endpoints must be same-Campaign Nodes; the CampaignID scopes the endpoint lookup and pins both composite FKs.

type NewKGNode

type NewKGNode struct {
	CampaignID uuid.UUID
	Type       KGNodeType
	Name       string
	Body       string
	GMPrivate  bool
}

NewKGNode is the input to CreateNode. node_type is set once at insert and never updated (ADR-0008: type is immutable).

type NewKGNodeAspect added in v0.5.0

type NewKGNodeAspect struct {
	ID        uuid.UUID
	Key       string
	Value     string
	GMPrivate bool
}

NewKGNodeAspect is one Aspect row as the editor supplies it. Position is NOT carried: Store.ReplaceNodeAspects assigns it from the slice order, so the author order is whatever the GM dragged the rows into and dense positions are an invariant rather than a client responsibility.

ID identifies an EXISTING row the caller is editing (uuid.Nil for a row the GM just added). Carrying it is what keeps a row's identity stable across saves: an unrecognised id is treated as a new row rather than trusted, so a stale client can add duplicates only by genuinely re-adding content, never by re-saving.

type NewMapPin added in v0.5.0

type NewMapPin struct {
	MapID         uuid.UUID
	CampaignID    uuid.UUID
	NodeID        uuid.UUID
	X, Y          float64
	LabelOverride string
	GMPrivate     bool
}

NewMapPin is the input to CreatePin. Coordinates are normalized 0..1 and the DB CHECK enforces it, so an out-of-range pin is refused rather than stored and silently clamped at render time.

type NewProviderConfig

type NewProviderConfig struct {
	TenantID              uuid.UUID
	Component             Component
	Provider              string
	Model                 string
	CredentialsCiphertext []byte
	CredentialsLast4      string
}

NewProviderConfig is the input to CreateProviderConfig. CredentialsCiphertext is the AES-GCM-sealed credential (see internal/storage/crypto); for the self-host voice path the real key lives in the OS keyring and this carries a sealed placeholder with CredentialsLast4="env" (ADR-0004 / #5 seam).

type NewSession

type NewSession struct {
	UserID    uuid.UUID
	Token     string
	ExpiresAt time.Time
	IP        string
	UA        string
}

NewSession is the input to CreateSession. Token is the opaque random secret the auth tier minted; ExpiresAt is the absolute expiry the validator enforces.

type NewToolGrant

type NewToolGrant struct {
	AgentID  uuid.UUID
	ToolName string
	Surface  GrantSurface
	Config   json.RawMessage
}

NewToolGrant is the input to CreateToolGrant. Config is the optional per-grant scope blob (jsonb); a nil/empty Config persists SQL NULL — "no narrowing" (dice). Surface picks the grant's Agent surface (#592, ADR-0062); the zero value means GrantSurfaceVoice, the pre-0062 meaning of every caller.

type NodeAppearance added in v0.5.0

type NodeAppearance struct {
	NodeID         uuid.UUID
	CampaignID     uuid.UUID
	VoiceSessionID uuid.UUID
	LineID         string
	At             time.Time
}

NodeAppearance is one recorded mention: a Node named in a committed Transcript Line.

type PartyMarker added in v0.5.0

type PartyMarker struct {
	MapID   uuid.NullUUID
	PinID   uuid.NullUUID
	X, Y    *float64
	MapName string
	// PinLabel and PinNodeID describe the Pin when the marker is at one.
	PinLabel     string
	PinNodeID    uuid.NullUUID
	MapGMPrivate bool
	PinHidden    bool
}

PartyMarker is a Voice Session's current position. MapID zero means no marker is set — the state every session starts in.

A marker is either AT a Pin (PinID set) or at a free position between pins (X/Y set): the party crossing the moor is as real a position as the party in the tavern, and forcing a Pin for it would mean inventing wiki entries for empty ground.

func (PartyMarker) Set added in v0.5.0

func (m PartyMarker) Set() bool

Set reports whether a marker is placed at all.

type Plan added in v0.2.1

type Plan struct {
	ID               uuid.UUID
	Slug             string
	DisplayName      string
	Description      string
	MonthlyPriceUSD  float64
	KeySource        string
	IncludedUsageUSD *float64
	Limits           json.RawMessage
	Archived         bool
	CreatedAt        time.Time
	UpdatedAt        time.Time
}

Plan is a catalog row.

type PlanSpec added in v0.2.1

type PlanSpec struct {
	Slug             string
	DisplayName      string
	Description      string
	MonthlyPriceUSD  float64
	KeySource        string // 'byok' | 'platform' (plan_key_source enum)
	IncludedUsageUSD *float64
	Limits           json.RawMessage
}

PlanSpec is the write shape SyncPlans upserts from the operator's catalog file (ADR-0054). Limits is raw JSON (validated upstream); nil stores '{}'.

type PlanSyncResult added in v0.2.1

type PlanSyncResult struct {
	Upserted int
	Archived int
}

PlanSyncResult reports what one SyncPlans call changed.

type PlanningMessage added in v0.8.0

type PlanningMessage struct {
	ID         uuid.UUID
	ThreadID   uuid.UUID
	CampaignID uuid.UUID
	Seq        int64
	Role       string
	Content    string
	CreatedAt  time.Time
}

PlanningMessage is one prose turn of a planning thread. Role is the two-value chat vocabulary ('user' / 'assistant'); Seq is the thread-local order the exchange loop appends under.

type PlanningThread added in v0.8.0

type PlanningThread struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	// Title is the thread's display name; auto-filled from the first user
	// message when the GM did not name it.
	Title     string
	CreatedAt time.Time
	UpdatedAt time.Time
}

PlanningThread is one persisted Butler planning chat conversation.

type PromptKGView added in v0.2.1

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

PromptKGView is the prompt-facing Knowledge Graph read surface (#450, ADR-0008): the type prompt-assembly code (Hot Context fact recall, the kg_query Tool adapter) holds for KG reads. Every method filters gm_private rows inside its SQL, and the type exposes NO unfiltered read — "reaches a prompt" and "may see gm_private" are separated by the type system instead of by remembering the right *Store method name at each call site. GM and web review surfaces keep the full-KG reads on *Store (SearchNodes, SimilarNodes, ListNodes), which never flow into prompt assembly.

The zero-lag filtering semantics are those of the underlying *Store reads; the gm_private exclusion is pinned by the seam integration test (TestPromptKG_NeverReturnsGMPrivate).

func (PromptKGView) AgentNodeFacts added in v0.2.1

func (v PromptKGView) AgentNodeFacts(ctx context.Context, agentID uuid.UUID) ([]KGNode, error)

AgentNodeFacts is the Agent's own edge-aware Node neighbourhood, gm-public only (see Store.AgentNodeFacts).

func (PromptKGView) SearchPublicNodes added in v0.2.1

func (v PromptKGView) SearchPublicNodes(ctx context.Context, campaignID uuid.UUID, query string, limit int) ([]KGNode, error)

SearchPublicNodes is the prompt-facing KG search: gm_private Nodes are excluded in the query, before the LIMIT (see Store.SearchPublicNodes).

type ProposalBlockedError added in v0.2.1

type ProposalBlockedError struct{ Reason string }

ProposalBlockedError is returned by ApproveKnowledgeProposal when the proposed write cannot land as-is (an unresolvable/ambiguous subject, a dangling anchor, an edge matrix violation or duplicate, or an unreadable payload). Reason is a human-actionable message the GM sees; the proposal row stays pending so the GM can fix the wiki and re-approve, or reject. The RPC layer maps it to CodeFailedPrecondition with Reason verbatim.

func (*ProposalBlockedError) Error added in v0.2.1

func (e *ProposalBlockedError) Error() string

type ProviderConfig

type ProviderConfig struct {
	ID                    uuid.UUID
	TenantID              uuid.UUID
	Component             Component
	Provider              string
	Model                 string
	CredentialsCiphertext []byte
	CredentialsLast4      string
	CreatedAt             time.Time
	UpdatedAt             time.Time
}

ProviderConfig is a Tenant-scoped, encrypted BYOK credential record binding a Component to a Provider (ADR-0004). Credentials are write-only after save; CredentialsCiphertext is AES-GCM, and only Last4 is plaintext for display.

type QueryMetrics added in v0.12.0

type QueryMetrics interface {
	// DBQuery records one completed query under a BOUNDED family label.
	DBQuery(query string, d time.Duration)
}

QueryMetrics is the storage-side seam for the query-latency histogram (#605). *observe.PrometheusRecorder satisfies it; storage depends on this local interface rather than on internal/observe, so the DB layer stays free of a metrics dependency and a test can substitute a sink.

type QueryTracer added in v0.12.0

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

QueryTracer implements pgx.QueryTracer, timing every query the pool runs and recording it under its bounded family label (#605). It is deliberately alloc-light on the hot path — the ANN search it measures runs inside the 250ms recall budget (ADR-0042), so End does a context read and one Observe: no logging, no map allocation, no SQL inspection.

SendBatch (pgx.BatchTracer) is out of scope: this tracer only implements QueryTracer, so batched statements are not timed.

func NewQueryTracer added in v0.12.0

func NewQueryTracer(rec QueryMetrics) *QueryTracer

NewQueryTracer returns a tracer recording into rec. Attach it to a pool via pgxpool.Config.ConnConfig.Tracer.

func (*QueryTracer) TraceQueryEnd added in v0.12.0

func (t *QueryTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData)

TraceQueryEnd records the elapsed time under the ctx's query family.

func (*QueryTracer) TraceQueryStart added in v0.12.0

func (t *QueryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context

TraceQueryStart stashes the start instant on the returned context.

type Session

type Session struct {
	ID         uuid.UUID
	UserID     uuid.UUID
	Token      string
	CreatedAt  time.Time
	LastSeenAt time.Time
	ExpiresAt  time.Time
	IP         string
	UA         string
}

Session is a server-side login session (ADR-0016): the Token is the opaque random secret carried in the glyphoxa_session cookie, and this row is the authority. ExpiresAt gates validity; deleting the row revokes instantly.

type SessionAppearance added in v0.5.0

type SessionAppearance struct {
	NodeID uuid.UUID
	LineID string
	At     time.Time
}

SessionAppearance is one session's appearance row, for the Campaign Bundle export (#547). It carries the Node id and the Line's own stable key — the two things an import has to remap or preserve.

type SessionPurgeCandidate

type SessionPurgeCandidate struct {
	VoiceSessionID uuid.UUID
	EndedAt        time.Time
}

SessionPurgeCandidate is an ended Voice Session the boot backstop must schedule a candidate purge for: its id plus the ended_at the 7-day horizon anchors to (ADR-0051), so a session that ended long ago is purged immediately rather than 7 days after boot.

type SignupParams added in v0.3.0

type SignupParams struct {
	User       UpsertUserParams
	TenantName string
	PlanSlug   string
	Session    NewSession
	// AUPAcceptedAt is when this signup acknowledged the Nutzungsbedingungen +
	// Datenschutzerklärung (#518): the OAuth start carried the acknowledgment
	// and the callback stamps its time here, so users.aup_accepted_at always
	// holds the LATEST acceptance (a returning open-mode login re-acknowledges
	// — the login screen requires the tick every time). The zero value writes
	// nothing; the auth tier refuses ack-less open-mode signups before calling.
	AUPAcceptedAt time.Time
}

SignupParams is the input to ProvisionSignup. Session.UserID is ignored — the provision fills it with the upserted user's id.

type SignupResult added in v0.3.0

type SignupResult struct {
	User    User
	Tenant  Tenant
	Session Session
	Created bool
}

SignupResult reports what ProvisionSignup did. Created is true when a fresh Tenant was founded (a first signup) — the callback uses it to route the user into onboarding rather than straight to the app.

type SpendCaps

type SpendCaps struct {
	SoftUSD *float64
	HardUSD *float64
}

SpendCaps is the get/set DTO for a Tenant's two spend caps (#130, ADR-0046), each nil when that cap is unset. It is the storage-layer value the session Manager maps onto its meter and the RPC round-trips; keeping it distinct from spend.Caps keeps storage free of a spend-package import.

type Store

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

Store reads and writes the core tables over a pgx connection pool (or a transaction, when created by Store.InTx).

func New

func New(pool *pgxpool.Pool) *Store

New wraps a pgx pool in a Store. The caller owns the pool's lifecycle.

func (*Store) AcquireOrRenewPresenceOwner added in v0.4.0

func (s *Store) AcquireOrRenewPresenceOwner(ctx context.Context, instanceID string, expiry time.Duration) (bool, error)

AcquireOrRenewPresenceOwner atomically claims or renews the singleton presence-owner row for instanceID and reports whether instanceID now owns it. The single upsert wins — inserts on an empty table, or updates the existing row — only when the row is already instanceID's (a renew, advancing the heartbeat) OR the incumbent's heartbeat is older than expiry (a dead owner's row, so a challenger takes over). When another live instance holds it the ON CONFLICT WHERE predicate is false, no row is written, and this returns false — the caller is a non-owner and must stay inactive.

func (*Store) ActiveSubscription added in v0.2.1

func (s *Store) ActiveSubscription(ctx context.Context, tenantID uuid.UUID) (Subscription, error)

ActiveSubscription returns a Tenant's active subscription, ErrNotFound when none (an unsubscribed tenant — the BYOK self-host default).

func (*Store) AddUsage added in v0.2.1

func (s *Store) AddUsage(ctx context.Context, usageRows []UsageRow) error

AddUsage upsert-accumulates ledger rows: an existing (tenant, day, component, provider, model) bucket has the quantities and estimate ADDED, a new bucket is inserted. Idempotence is NOT promised — the flush path must not double-send — but ordering is irrelevant and rows commute, so concurrent sessions of one tenant simply accumulate.

func (*Store) AgentLinkedNode

func (s *Store) AgentLinkedNode(ctx context.Context, agentID uuid.UUID) (KGNode, bool, error)

AgentLinkedNode returns the Node an Agent is linked to (the NPC-Node↔Agent link, ADR-0008; kg_node.agent_id), the anchor an own_node-scoped remember_knowledge proposal attaches to. ok=false (no error) means the Agent has no linked entry — the Tool handler refuses rather than proposing against a wrong Node. The link is unique per Agent, so at most one row exists.

func (*Store) AgentNodeFacts

func (s *Store) AgentNodeFacts(ctx context.Context, agentID uuid.UUID) ([]KGNode, error)

AgentNodeFacts returns the edge-aware Hot Context fact set for a Character NPC Agent (#133, ADR-0008 amendment): the Agent's own linked Node plus its edge-adjacent Nodes (a single hop in BOTH edge directions), gm-public only, newest-first within hop, capped — one round trip inside the kgfacts budget.

Semantics: traversal STARTS from the linked Node regardless of its gm_private, but gm_private filters SURFACING — the own Node and any neighbour that is gm_private is walked (its edges still expand) yet never returned, so a GM-only fact never reaches the prompt. An Agent with no linked Node yields an empty set (no campaign-wide fallback: the NPC injects only its own neighbourhood). The UNION dedupes multi-edge neighbours; min(hop) keeps the own Node at hop 0 even if an Edge also makes it a neighbour of itself's neighbour.

Aspects (#542) ride the same seam: each returned Node carries its PUBLIC Aspects only, filtered inside the aggregate's SQL. A gm_private Aspect on an otherwise public Node is therefore unreachable from prompt assembly by construction — which is the whole point of splitting visibility per fact.

func (*Store) AnyLiveVoiceSessionIntent added in v0.4.0

func (s *Store) AnyLiveVoiceSessionIntent(ctx context.Context) (bool, error)

AnyLiveVoiceSessionIntent reports whether ANY non-terminal intent exists — the split-mode health signal (#491, the claim-plane sibling of Manager.AnyLive) the web tier reads for the Discord health short-circuit.

func (*Store) AppendPlanningMessage added in v0.8.0

func (s *Store) AppendPlanningMessage(ctx context.Context, campaignID, threadID uuid.UUID, role, content string) (PlanningMessage, error)

AppendPlanningMessage appends one prose turn at the thread's next seq and bumps the thread's updated_at (the thread list orders by it). The thread row is locked (FOR UPDATE) inside the transaction first, which both yields a clean ErrNotFound for an unknown/foreign thread — BEFORE the seq computation can trip the UNIQUE (thread_id, seq) index on a cross-campaign probe — and serializes concurrent appends to the same thread so max+1 never collides.

func (*Store) ApplyKnowledgeDraft added in v0.3.0

func (s *Store) ApplyKnowledgeDraft(ctx context.Context, campaignID uuid.UUID, nodes []NewKGNode, edges []KnowledgeDraftEdge) ([]KGNode, []KGEdge, error)

ApplyKnowledgeDraft creates a GM-confirmed knowledge draft's Nodes and Edges in ONE transaction (#479): either the whole draft lands or none of it. Every Node insert reuses CreateNode's semantics (enum-cast type); every Edge insert reuses createEdgeTx, so a matrix-invalid or self edge is ErrInvalidEdge and a duplicate (from, to, type) is ErrConflict — identical to a GM-authored CreateEdge — and any such failure rolls the WHOLE draft back (nothing half-lands). An out-of-range edge index is ErrInvalidEdge. The caller pre-validates indices and the matrix (the RPC layer refuses obviously bad drafts with better messages); this re-check is the transactional authority.

func (*Store) ApproveKnowledgeProposal added in v0.2.1

func (s *Store) ApproveKnowledgeProposal(ctx context.Context, campaignID, id uuid.UUID) error

ApproveKnowledgeProposal lands a pending proposal's write on the Knowledge Graph and marks it approved — ATOMICALLY (#300, ADR-0052): the claim UPDATE and the KG write share one transaction, so a refused write rolls the claim back and the row stays pending (never a half-approved proposal, never a lost race). The claim is conditional on status='pending' and takes the row lock, so a concurrent double-approve sees 0 rows and yields ErrNotFound. An unreadable payload (wrong version / unknown kind) or an unlandable write (unresolvable/ambiguous subject, dangling anchor, edge matrix violation, duplicate, self-edge) yields a *ProposalBlockedError with a human reason and leaves the row pending.

Per ADR-0052 there is NO auto-merge: a fact/edge subject that names no wiki entry is refused with an actionable message, never silently created or fuzzily matched.

func (*Store) ArchiveCampaign

func (s *Store) ArchiveCampaign(ctx context.Context, tenantID, id uuid.UUID) (Campaign, error)

ArchiveCampaign marks a campaign archived and returns the updated row (#269). It is idempotent: COALESCE(archived_at, now()) keeps an already-archived campaign's original timestamp (the audit trail of WHEN it was first archived), so a re-archive is a no-op on the timestamp. In the same transaction it clears users.active_campaign_id for every operator whose durable /glyphoxa use selection pointed at this campaign — the decided "archived durable selection is treated as absent" (#265): the slash surface then falls to its /use hint and the web tier to its most-recent fallback, neither of which resolves an archived campaign. A missing id yields ErrNotFound.

It is TENANT-SCOPED (#473): the UPDATE matches (id, tenant_id), so a foreign-tenant id is invisible and yields ErrNotFound — a cross-tenant archive can never land. The durable-selection clear stays keyed on the campaign id, so it only nulls pointers at THIS campaign.

func (*Store) AuthenticateSession

func (s *Store) AuthenticateSession(ctx context.Context, token string) (User, error)

AuthenticateSession validates a session token and returns the owning user. It bumps last_seen_at as a side effect of a successful, non-expired lookup, in a single round trip. A missing or expired token yields ErrNotFound — the RPC layer maps that to CodeUnauthenticated. A SUSPENDED owner yields the same ErrNotFound: this is the ADR-0055 per-request authorization re-check, and it lives in this query because every gated request on both transports funnels through here — suspension takes effect on the very next request with zero extra round trips. The session row is not deleted (suspension is non-destructive; unsuspending restores the same token).

func (*Store) BillingReport added in v0.2.1

func (s *Store) BillingReport(ctx context.Context, from, to time.Time) ([]TenantBillingLine, error)

BillingReport aggregates revenue and estimated cost per tenant over [from, to) (ADR-0054): every subscription overlapping the window contributes its monthly price snapshot un-prorated (label it as such when surfacing), and the usage ledger contributes summed quantities + estimated USD. Tenants with usage but no subscription (BYOK) appear with an empty PlanSlug.

func (*Store) CampaignNodeNames added in v0.5.0

func (s *Store) CampaignNodeNames(ctx context.Context, campaignID uuid.UUID) ([]IndexableNode, error)

CampaignNodeNames returns every Node in a Campaign as (id, name).

gm_private entries are INCLUDED. An appearance is GM-facing retrieval, never a prompt read: the GM's own secret being mentioned at the table is exactly the kind of thing they want to find again, and hiding it here would make the index lie about their own campaign.

func (*Store) CampaignTags added in v0.5.0

func (s *Store) CampaignTags(ctx context.Context, campaignID uuid.UUID) ([]TaggedNode, error)

CampaignTags returns every (node, tag) pair in a Campaign, ordered so the client can build both the tag vocabulary and the per-node index from one read.

func (*Store) CancelPendingVoiceSessionControl added in v0.4.0

func (s *Store) CancelPendingVoiceSessionControl(ctx context.Context, id uuid.UUID) (bool, error)

CancelPendingVoiceSessionControl fails a still-pending control with 'requester timed out' — the requester's budget-expiry best-effort cancel. Fenced WHERE status='pending': a row the worker already finished is left untouched and false is returned (the worker won the race; the requester's honest "not confirmed" reply stands either way).

func (*Store) CharacterAgents

func (s *Store) CharacterAgents(ctx context.Context, campaignID uuid.UUID) ([]Agent, error)

CharacterAgents returns the Campaign's Character NPC Agents (agent_role = 'character'), excluding the auto-created Butler. The live voice slice has exactly one (the seeded NPC); #6's web app lists many.

func (*Store) ClaimJob

func (s *Store) ClaimJob(ctx context.Context, kinds []string, lease time.Duration) (Job, error)

ClaimJob atomically claims the single oldest runnable job in any of kinds and returns it in its post-claim state (status='running', attempts incremented, leased_until = now()+lease). Runnable means a pending job whose run_after has arrived, OR a running job whose lease has expired and still has attempts left (a crashed worker's abandoned lease). No runnable row yields ErrNotFound.

The claim is ONE atomic UPDATE whose target row is chosen by a `SELECT … FOR UPDATE SKIP LOCKED LIMIT 1` subquery: concurrent workers skip each other's locked candidates rather than block, so two workers claim two distinct jobs. The SKIP LOCKED sits on the SELECT subquery, never on the UPDATE.

func (*Store) ClaimVoiceSessionIntent added in v0.4.0

func (s *Store) ClaimVoiceSessionIntent(ctx context.Context, instanceID string) (VoiceSessionIntent, error)

ClaimVoiceSessionIntent atomically claims the single oldest PENDING intent for instanceID and returns it in its post-claim state (status='claimed', instance_id set, claimed_at/heartbeat_at = now()). No pending intent yields ErrNotFound.

The claim is ONE atomic UPDATE whose target is chosen by a `SELECT … FOR UPDATE SKIP LOCKED LIMIT 1` subquery, so two concurrent workers skip each other's locked candidates and claim two DISTINCT intents (never the same one) — exactly the job-runner idiom (ADR-0049). It claims 'pending' ONLY: a 'claimed'/'live' row whose worker crashed is NEVER re-claimed here (ADR-0006/0057 (e) — no mid-session takeover); ReapDeadVoiceSessionIntents marks such a row 'dead' instead, and the Tenant restarts.

func (*Store) CloseVoiceSession

func (s *Store) CloseVoiceSession(ctx context.Context, id uuid.UUID, status VoiceSessionStatus, lineCount int, endReason *string) (VoiceSession, error)

CloseVoiceSession closes a running Voice Session with an explicit terminal status and end_reason: it sets ended_at=now(), status, the final line_count, and end_reason (NULL when endReason is nil), returning the updated row. A missing id yields ErrNotFound. It is the single terminal-write seam (#123): [EndVoiceSession] delegates to it for a clean stop ('ended', NULL reason), and the session Manager calls it directly with 'failed' + the readable cause on a fatal gateway rejection.

func (*Store) CompleteJob

func (s *Store) CompleteJob(ctx context.Context, id uuid.UUID, attempts int) error

CompleteJob marks the caller's claimed generation of a job done and clears its lease. attempts is the claimed Job's attempts (the claim generation). A missing id OR a superseded claim (a newer lease bumped attempts) yields ErrNotFound.

func (*Store) CountJobBacklog

func (s *Store) CountJobBacklog(ctx context.Context, kinds []string) (map[string]int, error)

CountJobBacklog returns, per kind, the number of currently-runnable jobs — the same runnable predicate ClaimJob uses (pending-and-due, or running-with-an- expired-lease-and-attempts-left). It backs the backlog gauge (ADR-0032). A kind with no runnable jobs is absent from the map (the caller reads it as zero).

func (*Store) CountTranscriptLines

func (s *Store) CountTranscriptLines(ctx context.Context, sessionID uuid.UUID) (int, error)

CountTranscriptLines returns the number of persisted Lines for a Voice Session — the authoritative line_count the summary records on Stop (#74): rows == distinct lines, so it matches the persisted history. An unknown session is 0.

func (*Store) CountUnembeddedChunks

func (s *Store) CountUnembeddedChunks(ctx context.Context) (int, error)

CountUnembeddedChunks returns the number of Transcript Chunks still awaiting an embedding (embedding IS NULL) — the embedding-backlog gauge value (#104). It is process-wide (no tenant/campaign filter): ADR-0032 keeps that cardinality off the metric, so the gauge is a single global number.

func (*Store) CountUnembeddedNodes added in v0.2.1

func (s *Store) CountUnembeddedNodes(ctx context.Context) (int, error)

CountUnembeddedNodes returns the number of Knowledge Graph Nodes still awaiting an embedding (embedding IS NULL) — the node embedding-backlog gauge value (#300, ADR-0032), mirroring CountUnembeddedChunks. Process-wide (no campaign filter) to keep the metric's cardinality bounded.

func (*Store) CountUnembeddedNodesInCampaign added in v0.5.0

func (s *Store) CountUnembeddedNodesInCampaign(ctx context.Context, campaignID uuid.UUID) (int, error)

CountUnembeddedNodesInCampaign counts a Campaign's Nodes still awaiting an embedding (#536). The probable-duplicate scan cannot see them, so the health panel reports the count rather than letting "no duplicates" imply a clean bill of health the check could not give.

Distinct from Store.CountUnembeddedNodes, which is the process-wide gauge (kept campaign-free to bound metric cardinality, ADR-0032).

func (*Store) CreateAgent

func (s *Store) CreateAgent(ctx context.Context, a NewAgent) (uuid.UUID, error)

CreateAgent inserts an Agent and returns its generated ID. Inserting a second Butler in a Campaign violates the partial-unique index (ADR-0009). A Character is assigned the next round-robin speaker-colour slot for its Campaign (stable once stored); the Butler keeps slot 0.

func (*Store) CreateAgentWithNPCNode added in v0.3.0

func (s *Store) CreateAgentWithNPCNode(ctx context.Context, a NewAgent) (uuid.UUID, error)

CreateAgentWithNPCNode creates an Agent and — for a Character — its linked NPC Knowledge Graph Node in ONE transaction (#479, ADR-0008 second amendment): every new Character NPC starts with a wiki entry named after it, carrying the agent_id "voiced by" link. The Node starts with an empty body and gm_private=false (the Persona is NOT copied — persona is how the character speaks, the Node body is what the world knows). A non-character role creates no Node. Deleting the Agent later leaves the Node as a normal wiki-only entry (agent_id ON DELETE SET NULL).

func (*Store) CreateBoard added in v0.5.0

func (s *Store) CreateBoard(ctx context.Context, campaignID uuid.UUID, name string) (KGBoard, error)

CreateBoard makes an empty prep board.

func (*Store) CreateCampaign

func (s *Store) CreateCampaign(ctx context.Context, c NewCampaign) (uuid.UUID, error)

CreateCampaign inserts a Campaign and returns its generated ID. The auto-Butler trigger (ADR-0009) inserts the campaign's 'Glyphoxa' Butler as a side effect.

func (*Store) CreateCharacter

func (s *Store) CreateCharacter(ctx context.Context, n NewCharacter) (uuid.UUID, error)

CreateCharacter inserts a Player Character into a Campaign and returns its id. A second Character for the same (campaign, discord_user_id) violates the unique index and yields ErrConflict (one Character per Discord User per Campaign).

func (*Store) CreateEdge

func (s *Store) CreateEdge(ctx context.Context, e NewKGEdge) (KGEdge, error)

CreateEdge inserts a typed directional Edge after checking both endpoints exist in the given Campaign and the (type, from-type, to-type) combination is valid. A self-edge is rejected up front (ErrInvalidEdge); a missing or cross-campaign endpoint yields ErrNotFound (the endpoint SELECT is Campaign-scoped, so a foreign Node is simply invisible); an invalid combination yields ErrInvalidEdge; a duplicate (from, to, type) yields ErrConflict. Validation on the immutable node_type is sound without a trigger because a Node's type never changes.

func (*Store) CreateHighlight

func (s *Store) CreateHighlight(ctx context.Context, h Highlight) error

CreateHighlight inserts one detected highlight as a 'candidate' (the Saver's worker calls it after the clip is stored, #308). id/status/clip metadata are caller-supplied so the row and its blob.Key agree; empty SpeakerIDs are stored as the empty array (never NULL). A blank Status defaults to 'candidate'.

func (*Store) CreateKnowledgeProposal

func (s *Store) CreateKnowledgeProposal(ctx context.Context, campaignID, agentID uuid.UUID, proposedWrite []byte) error

CreateKnowledgeProposal inserts a pending proposal and returns the persisted row. proposedWrite is the raw jsonb payload (the caller marshals it). This layer does NO dedup by design: exact/normalized write-time dedup lives one layer up in the Tool handler (pkg/tool remember_knowledge, #411), which suppresses a repeat BEFORE calling here; genuine near-duplicates that differ in wording still land side-by-side for the GM to merge or reject (ADR-0052: similarity is a hint, not a semantic judgment — no auto-merge).

func (*Store) CreateMap added in v0.5.0

func (s *Store) CreateMap(ctx context.Context, m NewCampaignMap) (CampaignMap, error)

CreateMap inserts a Map and returns the persisted row. The caller writes the blob first: a row that references missing bytes is worse than an orphaned blob, which the seam's reconciliation can find.

func (*Store) CreateNode

func (s *Store) CreateNode(ctx context.Context, n NewKGNode) (KGNode, error)

CreateNode inserts a Knowledge Graph Node and returns the persisted row. The node_type is cast server-side to the kg_node_type enum, so an out-of-enum type is rejected by Postgres rather than silently stored.

func (*Store) CreateNodeWithAspects added in v0.5.0

func (s *Store) CreateNodeWithAspects(ctx context.Context, n NewKGNode, aspects []NewKGNodeAspect) (KGNode, error)

CreateNodeWithAspects creates a Node and its Aspects in ONE transaction (#542). Two statements would leave a created entry with no aspects when the second failed — and the client, seeing only an opaque error, cannot tell whether to retry (duplicating the entry) or not.

func (*Store) CreatePin added in v0.5.0

func (s *Store) CreatePin(ctx context.Context, n NewMapPin) (MapPin, error)

CreatePin pins a Node onto a Map. A duplicate (map, node) yields ErrConflict — one Pin per Node per Map, though the same Node may be pinned on several Maps (the city map AND the tavern floor plan). A Node or Map from another Campaign is refused by the composite FKs and yields ErrNotFound.

func (*Store) CreatePlanningThread added in v0.8.0

func (s *Store) CreatePlanningThread(ctx context.Context, campaignID uuid.UUID, title string) (PlanningThread, error)

CreatePlanningThread inserts a thread for the campaign and returns it. title may be empty — the first exchange auto-fills it.

func (*Store) CreateProviderConfig

func (s *Store) CreateProviderConfig(ctx context.Context, p NewProviderConfig) (uuid.UUID, error)

CreateProviderConfig inserts a Provider Config and returns its generated ID.

func (*Store) CreateSession

func (s *Store) CreateSession(ctx context.Context, n NewSession) (Session, error)

CreateSession inserts a session row and returns it.

func (*Store) CreateTenant

func (s *Store) CreateTenant(ctx context.Context, name string) (uuid.UUID, error)

CreateTenant inserts a Tenant and returns its generated ID.

func (*Store) CreateToolGrant

func (s *Store) CreateToolGrant(ctx context.Context, g NewToolGrant) (uuid.UUID, error)

CreateToolGrant inserts a Tool Grant and returns its generated ID. An empty Config is stored as SQL NULL. A duplicate (agent_id, tool_name, surface) violates the UNIQUE index — an Agent grants a Tool at most once per surface (ADR-0029, ADR-0062).

func (*Store) CreateVoiceSession

func (s *Store) CreateVoiceSession(ctx context.Context, campaignID uuid.UUID) (VoiceSession, error)

CreateVoiceSession opens a Voice Session for a Campaign: it INSERTs a row with status='running' and started_at=now() and returns it. The SessionManager holds the returned id to End the session on Stop.

func (*Store) CreateVoiceSessionControl added in v0.4.0

func (s *Store) CreateVoiceSessionControl(ctx context.Context, c VoiceSessionControl) (VoiceSessionControl, error)

CreateVoiceSessionControl writes a pending control row for an intent and returns it. The requester then polls GetVoiceSessionControl until the hosting worker writes a terminal status.

func (*Store) CreateVoiceSessionIntent added in v0.4.0

func (s *Store) CreateVoiceSessionIntent(ctx context.Context, tenantID, campaignID uuid.UUID, voiceChannelID string) (VoiceSessionIntent, error)

CreateVoiceSessionIntent writes a 'pending' claim-plane row for a Tenant's Campaign and returns it. voiceChannelID carries the start's explicit voice-channel pick across the plane; empty means the worker uses the guild's Default Voice Channel. A second create while the Tenant already has a non-terminal (pending/claimed/live) intent trips the one-live-per-tenant partial UNIQUE index (23505) and yields ErrIntentActive — the per-Tenant single-active guard, now durable in the DB rather than the in-process Manager.

func (*Store) DeleteAgent

func (s *Store) DeleteAgent(ctx context.Context, campaignID, id uuid.UUID) error

DeleteAgent removes a Character NPC by id, scoped to its owning Campaign (#342): every clause matches (id, campaign_id), so an Agent in another Campaign is invisible — it neither exists nor deletes — and yields ErrNotFound, refusing a cross-campaign delete. Deleting a Butler is rejected with ErrButlerUndeletable (ADR-0009): the guarded DELETE leaves a Butler row untouched, and the wrapping CTE reports whether the id existed in the Campaign and whether it was a Butler in one atomic round-trip — so a missing id yields ErrNotFound and a Butler yields ErrButlerUndeletable, distinct from "deleted nothing".

func (*Store) DeleteBoard added in v0.5.0

func (s *Store) DeleteBoard(ctx context.Context, campaignID, id uuid.UUID) error

DeleteBoard removes a prep board and its entries. The ENTRIES themselves are untouched — a board is a shortlist, not ownership.

func (*Store) DeleteCampaign

func (s *Store) DeleteCampaign(ctx context.Context, tenantID, id uuid.UUID) error

DeleteCampaign permanently removes an ALREADY-ARCHIVED campaign (#269). The single DELETE cascades to everything owned by the campaign — Agents (and their Tool Grants), Knowledge Graph Nodes/Edges, Voice Sessions (and their Transcript Lines), and Transcript Chunks — via the ON DELETE CASCADE foreign keys the schema already declares (00001 agents/transcript_chunk, 00006 voice_sessions, 00007 transcript_line, 00010 kg_node, 00012 kg_edge, 00013 tool_agent_grant); users.active_campaign_id is nulled via its ON DELETE SET NULL (00014). The Butler is removed through the agents CASCADE, deliberately NOT through DeleteAgent's butler guard (ADR-0009): a campaign delete takes its Butler with it. The WHERE archived_at IS NOT NULL clause makes the delete refuse a non-archived campaign; when no row is affected, GetCampaignInTenant disambiguates a missing campaign (ErrNotFound) from a live one (ErrNotArchived). This is irrecoverable removal of play history including transcript PII — no soft-delete retention window (#265).

It is TENANT-SCOPED (#473): the DELETE matches (id, tenant_id), so a foreign-tenant id is invisible — it disambiguates to ErrNotFound (never ErrNotArchived, which would confirm the id exists), and a cross-tenant delete can never cascade away the victim's play history.

func (*Store) DeleteCampaignWithJob

func (s *Store) DeleteCampaignWithJob(ctx context.Context, tenantID, id uuid.UUID, jobKind string, jobPayload []byte) error

DeleteCampaignWithJob hard-deletes an archived campaign AND enqueues a follow-up job in the SAME transaction (#308, ADR-0048/0049): the job row exists if and only if the delete committed. This closes the blob-orphan window a delete-then-enqueue would open — a refused/failed delete never leaves a sweep that would drop a surviving campaign's clips, and a crash right after the delete never loses the sweep. The delete's error mapping (ErrNotFound / ErrNotArchived) is unchanged. jobPayload must be non-empty; callers with nothing to sweep use [DeleteCampaign]. It is TENANT-SCOPED (#473): the delete matches (id, tenant_id).

func (*Store) DeleteCharacter

func (s *Store) DeleteCharacter(ctx context.Context, campaignID, id uuid.UUID) error

DeleteCharacter removes a Character by id, scoped to its owning Campaign (#342): the DELETE matches (id, campaign_id), so a Character in another Campaign is not deleted and yields ErrNotFound — a cross-campaign delete is refused server-side. A missing id likewise yields ErrNotFound so the RPC can distinguish "gone" from "never existed".

func (*Store) DeleteEdge

func (s *Store) DeleteEdge(ctx context.Context, campaignID, id uuid.UUID) error

DeleteEdge removes a typed Edge by id, scoped to its owning Campaign (#342): the DELETE matches (id, campaign_id), so an Edge in another Campaign is not deleted and yields ErrNotFound — a cross-campaign delete is refused server-side. A missing id likewise yields ErrNotFound.

func (*Store) DeleteHighlight

func (s *Store) DeleteHighlight(ctx context.Context, tenantID, id uuid.UUID) (string, error)

DeleteHighlight removes one highlight within the tenant and returns its clip_key so the caller can drop the blob through the seam (ADR-0048). A missing id (or foreign tenant) yields ErrNotFound. The blob delete is the caller's responsibility and runs BEFORE this in the RPC (blob-then-row), but the key is returned so a delete driven off a prior read still has it.

func (*Store) DeleteMap added in v0.5.0

func (s *Store) DeleteMap(ctx context.Context, campaignID, id uuid.UUID) (blobKey string, err error)

DeleteMap removes a Map and returns the blob key the caller must then delete through the seam (ADR-0048: deletion goes through the seam, not FK cascade). Returning the key rather than deleting the blob here keeps storage free of a blob dependency, and makes the caller's obligation explicit.

Child Maps are NOT cascaded: parent_map_id is ON DELETE SET NULL, so deleting a middle map lifts its children to the top level instead of silently taking a subtree with it.

func (*Store) DeleteNode

func (s *Store) DeleteNode(ctx context.Context, campaignID, id uuid.UUID) (portraitBlobKey string, err error)

DeleteNode removes a Knowledge Graph Node by id, scoped to its owning Campaign (#342): the DELETE matches (id, campaign_id), so a Node in another Campaign is not deleted and yields ErrNotFound — a cross-campaign delete is refused. A missing id likewise yields ErrNotFound so the RPC can distinguish "gone" from "never existed".

It returns the deleted Node's portrait blob key (” when it had none) so the caller can release the bytes through the seam (#590, ADR-0048) — after the DELETE, nothing in the database names them, mirroring DeleteMap.

func (*Store) DeletePin added in v0.5.0

func (s *Store) DeletePin(ctx context.Context, campaignID, id uuid.UUID) error

DeletePin unpins a Node from a Map, scoped to its Campaign.

func (*Store) DeletePlanningThread added in v0.8.0

func (s *Store) DeletePlanningThread(ctx context.Context, campaignID, id uuid.UUID) error

DeletePlanningThread removes a thread and (via the composite FK cascade) its messages. A thread outside the campaign yields ErrNotFound.

func (*Store) DeleteSession

func (s *Store) DeleteSession(ctx context.Context, token string) error

DeleteSession removes a session row by token (logout / revocation). Deleting a token that no longer exists is not an error — logout is idempotent.

func (*Store) DeleteSessionCandidates

func (s *Store) DeleteSessionCandidates(ctx context.Context, voiceSessionID uuid.UUID) (int, error)

DeleteSessionCandidates removes every remaining CANDIDATE highlight row for a Voice Session (the 7-day purge, #308/ADR-0051), returning how many rows were deleted. Promoted rows are untouched. Idempotent: a second run deletes nothing. The caller drops the blobs first via ListSessionCandidateClipKeys.

func (*Store) DeleteTapeConsent

func (s *Store) DeleteTapeConsent(ctx context.Context, campaignID uuid.UUID, discordUserID string) error

DeleteTapeConsent revokes a Speaker's tape consent for a Campaign (#306). It is idempotent — deleting an absent row is a no-op — so a double revoke is harmless.

func (*Store) DeleteToolGrant

func (s *Store) DeleteToolGrant(ctx context.Context, agentID uuid.UUID, toolName string) error

DeleteToolGrant removes an Agent's VOICE grant of the named Tool. Deleting a grant that is not present yields ErrNotFound (so the caller can tell "removed" from "was never there"). Removing the row is how a GM revokes a Tool: after hydration the Agent's GrantSet no longer carries it, so the LLM is never shown the Tool and cannot call it. Voice-only keeps the pre-0062 meaning for every caller (the grant editor RPC, the bundle importer); chat rows have no mutation surface in v1 — they are the trigger-seeded defaults.

func (*Store) DeleteTranscriptLine added in v0.5.0

func (s *Store) DeleteTranscriptLine(ctx context.Context, sessionID uuid.UUID, lineID string) error

DeleteTranscriptLine removes one transcript Line by its replay key — the reconciliation half of the LINE grain's delivered-only invariant (#437, ADR-0040 amendment 2026-07-22). The Relay persists optimistically at TTSInvoked and calls this through the SAME single-writer queue when the turn ends having delivered zero sentences, so replay never shows text the room never heard. Deleting a row that is not there is NOT an error: the reconcile is best-effort and may race a dropped (queue-full) UPSERT that never landed.

func (*Store) EndTenantPlan added in v0.2.1

func (s *Store) EndTenantPlan(ctx context.Context, tenantID uuid.UUID) error

EndTenantPlan ends a Tenant's active subscription (cancellation). ErrNotFound when the tenant has no active subscription.

func (*Store) EndVoiceSession

func (s *Store) EndVoiceSession(ctx context.Context, id uuid.UUID, lineCount int) (VoiceSession, error)

EndVoiceSession closes a running Voice Session cleanly: status='ended' with a NULL end_reason and the final line_count, returning the updated row. A missing id yields ErrNotFound. It is a thin wrapper over [CloseVoiceSession] — the clean-stop path that leaves end_reason NULL (distinct from orphaned/failed).

func (*Store) EnqueueJob

func (s *Store) EnqueueJob(ctx context.Context, kind string, payload []byte, maxAttempts int) (uuid.UUID, error)

EnqueueJob inserts a pending job of the given kind carrying payload (jsonb), returning its id. A non-positive maxAttempts takes DefaultJobMaxAttempts. The job is immediately runnable (run_after defaults to now()).

func (*Store) EnqueueJobAt

func (s *Store) EnqueueJobAt(ctx context.Context, kind string, payload []byte, maxAttempts int, runAfter time.Time) (uuid.UUID, error)

EnqueueJobAt is EnqueueJob with an explicit run_after: the job stays pending (not runnable) until runAfter arrives. It backs deferred work like the Session Highlights 7-day candidate purge (#308, ADR-0051/0049). A zero runAfter falls back to now() (immediately runnable), matching EnqueueJob.

func (*Store) FindCampaignByName

func (s *Store) FindCampaignByName(ctx context.Context, tenantID uuid.UUID, name string) (Campaign, error)

FindCampaignByName returns the Tenant's Campaign with the given name, or ErrNotFound. It returns the full row rather than just the ID so a caller wiring a Voice Session gets the Campaign Language (the matcher's phonetic scheme, #199) from the same lookup.

func (*Store) FindTenantByName

func (s *Store) FindTenantByName(ctx context.Context, name string) (Tenant, error)

FindTenantByName returns the Tenant with the given name, or ErrNotFound. Used by the idempotent seed to detect an already-seeded database.

func (*Store) FinishVoiceSessionControl added in v0.4.0

func (s *Store) FinishVoiceSessionControl(ctx context.Context, id uuid.UUID, status VoiceSessionControlStatus, resultIDs []string, lastError string) (VoiceSessionControl, error)

FinishVoiceSessionControl writes a control's terminal state (done/failed) with its result ids / last_error and ended_at = now(). Fenced WHERE status='executing' (the worker must have claimed it via StartVoiceSessionControl first) so a lost race (the sweep won a stale executing row) yields ErrNotFound and the caller does not overwrite a settled row.

func (*Store) FinishVoiceSessionIntent added in v0.4.0

func (s *Store) FinishVoiceSessionIntent(ctx context.Context, id uuid.UUID, instanceID string, status VoiceSessionIntentStatus, lastError string) (VoiceSessionIntent, error)

FinishVoiceSessionIntent writes a terminal state (done/failed/dead) for instanceID's intent with a recorded last_error and ended_at = now(). Fenced by (id, instance_id) and a non-terminal current status so a superseded caller (the reaper already marked it dead) matches no row and yields ErrNotFound. The worker calls it once its local session has fully wound down.

func (*Store) FirstLineIDAtOrAfter added in v0.8.0

func (s *Store) FirstLineIDAtOrAfter(ctx context.Context, voiceSessionID uuid.UUID, at time.Time) (string, error)

FirstLineIDAtOrAfter returns the line_id of a Voice Session's earliest Transcript Line at or after the given time — the deep-link anchor a semantic Transcript Chunk search hit resolves to (#591): a chunk is the 3–6-utterance retrieval grain (ADR-0011) with no line identity of its own, so the palette anchors on the first Line the chunk's window covers. Ordered by (ts, seq) so two Lines sharing a timestamp resolve deterministically. No Line at/after the time (a chunk flushed after the last persisted Line) is ErrNotFound — the caller renders the hit without a scroll target, not an error.

func (*Store) FirstTenant

func (s *Store) FirstTenant(ctx context.Context) (Tenant, error)

FirstTenant returns the earliest-created Tenant, or ErrNotFound when the DB holds none. The `glyphoxa seed -bundle` path uses it to land a bundle beside an already-provisioned Tenant instead of minting a duplicate one (ADR-0053): the ordering is deterministic (created_at then id) so a multi-tenant DB always resolves the same Tenant.

func (*Store) GetActiveCampaign

func (s *Store) GetActiveCampaign(ctx context.Context) (Campaign, error)

GetActiveCampaign returns the "active" campaign: the most-recently-created one, GLOBALLY. It is the deliberately tenant-FREE most-recent fallback the standalone voice node's boot path uses (cmd/glyphoxa, ADR-0039 single-operator): with one Tenant the global latest IS that Tenant's latest. The web/RPC tier uses Store.GetActiveCampaignInTenant instead so a stranger never falls through to another tenant's latest campaign (#473). Archived campaigns are excluded from this fallback (#269): an only-archived DB resolves to ErrNotFound, so an archived campaign can never be the implicit Active Campaign nor start a Voice Session. No campaign yields ErrNotFound (the RPC layer maps it to Connect CodeNotFound).

func (*Store) GetActiveCampaignForUserInTenant added in v0.3.0

func (s *Store) GetActiveCampaignForUserInTenant(ctx context.Context, tenantID uuid.UUID, discordUserID string) (Campaign, error)

GetActiveCampaignForUserInTenant is the tenant-scoped durable-selection read (#473, and the ONLY per-operator durable read after #490 removed the tenant-free GetActiveCampaignForUser — every slash/RPC caller now carries a resolved Tenant, so a tenant-free read can no longer be grabbed by the next handler): it resolves the operator's active_campaign_id ONLY when the selected campaign is in the caller's tenant (`AND c.tenant_id = $2`). A selection pointing at another tenant's campaign — which SetActiveCampaign's FK-only write cannot prevent by itself — reads back as ErrNotFound here, so it can never pivot a stranger's campaign-scoped surfaces onto the victim tenant (self-signup design §0a). The standalone voice node uses the context-free GetOperatorActiveCampaign instead. Absent-selection semantics: no row / no selection / deleted (FK ON DELETE SET NULL) / archived (#269) → ErrNotFound, so the caller falls through to the GetActiveCampaignInTenant most-recent fallback. The columns are qualified to `c` because users and campaign share id/name/created_at/updated_at.

func (*Store) GetActiveCampaignInTenant added in v0.3.0

func (s *Store) GetActiveCampaignInTenant(ctx context.Context, tenantID uuid.UUID) (Campaign, error)

GetActiveCampaignInTenant returns the tenant's most-recently-created active campaign, or ErrNotFound (#473) — the tenant-scoped most-recent fallback the web/RPC Active-Campaign resolution walks last. The `WHERE tenant_id = $1` guard keeps a caller from ever falling through to another tenant's latest campaign. Archived campaigns are excluded (#269), matching GetActiveCampaign.

func (*Store) GetAdmissionPosture added in v0.3.0

func (s *Store) GetAdmissionPosture(ctx context.Context) (string, error)

GetAdmissionPosture returns the recorded Admission Mode ('allowlist' or 'open'), or ErrNotFound when no posture has ever been recorded (a pre-0055 deployment's first boot). The value is stored verbatim; vocabulary validation lives in the auth tier (auth.ParseAdmissionMode).

func (*Store) GetAgent

func (s *Store) GetAgent(ctx context.Context, id uuid.UUID) (Agent, error)

GetAgent loads one Agent by id.

func (*Store) GetButler

func (s *Store) GetButler(ctx context.Context, campaignID uuid.UUID) (Agent, error)

GetButler loads a Campaign's Butler (exactly one per Campaign, ADR-0009).

func (*Store) GetCampaign

func (s *Store) GetCampaign(ctx context.Context, id uuid.UUID) (Campaign, error)

GetCampaign loads one Campaign by id, or ErrNotFound. It is the tenant-FREE read the standalone voice node / slash surface, the bundle export/import, and the recap engine use — surfaces that carry a server-derived campaign id, never a client-supplied one (ADR-0039 single-operator pass-through). The web/RPC tier that takes a client id uses Store.GetCampaignInTenant instead so a foreign id can never resolve. It backs the /glyphoxa use resolution step that turns a live Voice Session's campaign_id back into the full Campaign (#108).

func (*Store) GetCampaignInTenant added in v0.3.0

func (s *Store) GetCampaignInTenant(ctx context.Context, tenantID, id uuid.UUID) (Campaign, error)

GetCampaignInTenant loads one Campaign by id WITHIN the tenant, or ErrNotFound (#473). The `AND tenant_id = $2` guard means a campaign owned by another tenant reads back as absent — never a permission-style error that would confirm the id exists (self-signup design §0a). It is the web/RPC-tier read: every handler that takes a client-supplied campaign id resolves it through here, threading auth.TenantID(ctx) down, so cross-tenant reads/writes are refused at the query.

func (*Store) GetCampaignShareChannel added in v0.2.1

func (s *Store) GetCampaignShareChannel(ctx context.Context, campaignID uuid.UUID) (string, error)

GetCampaignShareChannel returns the Campaign's remembered highlight-share text channel id, or "" when none has been chosen yet. An unknown campaign is ErrNotFound (distinct from the empty-string "never shared" state of a known one).

func (*Store) GetCharacterByDiscordUser

func (s *Store) GetCharacterByDiscordUser(ctx context.Context, campaignID uuid.UUID, discordUserID string) (Character, error)

GetCharacterByDiscordUser resolves the Character a Discord User plays in a Campaign, or ErrNotFound. It is the speaker → Character lookup Address Detection / transcript attribution consume (#281). The (campaign_id, discord_user_id) unique index guarantees at most one row, so a cross-campaign lookup for the same Discord User simply misses.

func (*Store) GetDeploymentConfig

func (s *Store) GetDeploymentConfig(ctx context.Context, tenantID uuid.UUID) (DeploymentConfig, error)

GetDeploymentConfig loads a Tenant's deployment config, or ErrNotFound when nothing has been saved yet (the Configuration screen treats that as the empty, key-needed state).

func (*Store) GetEmbeddingsProviderConfig

func (s *Store) GetEmbeddingsProviderConfig(ctx context.Context) (ProviderConfig, error)

GetEmbeddingsProviderConfig returns the most-recently-updated 'embeddings' Provider Config, or ErrNotFound when none is bound. Process-wide (no tenant filter), mirroring GetActiveCampaign's single-operator posture (ADR-0039): the backfill worker resolves ONE embeddings provider for the process. Not-found is not fatal — the worker falls back to the local Ollama default (ADR-0004/0011).

func (*Store) GetHighlight

func (s *Store) GetHighlight(ctx context.Context, tenantID, id uuid.UUID) (Highlight, error)

GetHighlight loads one highlight by id within the tenant, or ErrNotFound. The tenant guard means a foreign-tenant id reads as absent (never leaked).

func (*Store) GetJob

func (s *Store) GetJob(ctx context.Context, id uuid.UUID) (Job, error)

GetJob loads one job by id, or ErrNotFound. It backs dead-letter inspection and tests; the runner itself never reads a job back after acting on it.

func (*Store) GetLatestVoiceSession

func (s *Store) GetLatestVoiceSession(ctx context.Context, campaignID uuid.UUID) (VoiceSession, error)

GetLatestVoiceSession returns a Campaign's most-recently-started Voice Session, or ErrNotFound when none has ever run. It backs the Session screen's idle last-session summary (#72): when no session is active, the screen shows when the prior session ended and its line count.

func (*Store) GetLiveVoiceSessionIntentForTenant added in v0.4.0

func (s *Store) GetLiveVoiceSessionIntentForTenant(ctx context.Context, tenantID uuid.UUID) (VoiceSessionIntent, error)

GetLiveVoiceSessionIntentForTenant returns the Tenant's current non-terminal (pending/claimed/live) intent, or ErrNotFound when the Tenant has none — the per-Tenant read backing IntentControl.Active (the split-mode sibling of Manager.Active). The one-live-per-tenant index guarantees at most one row matches.

func (*Store) GetMap added in v0.5.0

func (s *Store) GetMap(ctx context.Context, campaignID, id uuid.UUID) (CampaignMap, error)

GetMap loads one Map scoped to its Campaign (#342). A Map in another Campaign yields ErrNotFound.

func (*Store) GetOperatorActiveCampaign

func (s *Store) GetOperatorActiveCampaign(ctx context.Context) (Campaign, error)

GetOperatorActiveCampaign returns the durable Active Campaign selection of the single operator, for a surface with NO logged-in user context — the standalone voice node (#323, ADR-0039 single-operator pass-through). It is the context-free analogue of GetActiveCampaignForUserInTenant: it joins any users row carrying a non-null active_campaign_id to a non-archived campaign. Single operator, so at most one such selection is meaningful; a tie breaks on the most-recently-updated users row — the freshest login-or-selection, NOT strictly the freshest campaign selection: UpsertUser bumps users.updated_at on EVERY OAuth login, not only on SetActiveCampaign, so `ORDER BY u.updated_at` is a login clock, not a selection clock. Acceptable under the single-operator model (ADR-0039), where at most one operator has a durable selection anyway. ErrNotFound when no operator has a durable, non-archived selection (deleted/archived selections are treated as absent, matching GetActiveCampaignForUserInTenant), so the caller falls through to the GetActiveCampaign recent fallback.

func (*Store) GetPartyMarker added in v0.5.0

func (s *Store) GetPartyMarker(ctx context.Context, campaignID, sessionID uuid.UUID) (PartyMarker, error)

GetPartyMarker reads a Voice Session's marker, joined to the Map and Pin it names so a caller can render or describe it without further reads.

A marker whose Map or Pin has been deleted reads as UNSET rather than dangling: the FKs are ON DELETE SET NULL, so the row degrades to "no marker" on its own.

func (*Store) GetPendingKnowledgeProposal added in v0.2.1

func (s *Store) GetPendingKnowledgeProposal(ctx context.Context, campaignID, id uuid.UUID) (KnowledgeProposal, error)

GetPendingKnowledgeProposal loads a single PENDING proposal by id, scoped to its Campaign and joined to its authoring Agent's name (#300, ADR-0052). An already-reviewed or missing id — or one in another Campaign — yields ErrNotFound; the review surface uses it to build the similarity-hint query for a live proposal only.

func (*Store) GetPin added in v0.5.0

func (s *Store) GetPin(ctx context.Context, campaignID, id uuid.UUID) (MapPin, error)

GetPin loads one Pin with its joined Node fields, scoped to its Campaign.

func (*Store) GetPlanBySlug added in v0.3.0

func (s *Store) GetPlanBySlug(ctx context.Context, slug string) (Plan, error)

GetPlanBySlug returns the plan with slug, archived or not — the read stays faithful and the caller decides whether archived is a refusal (the ADR-0055 open-mode boot preflight does exactly that). ErrNotFound for an unknown slug.

func (*Store) GetPlanningThread added in v0.8.0

func (s *Store) GetPlanningThread(ctx context.Context, campaignID, id uuid.UUID) (PlanningThread, error)

GetPlanningThread loads one thread scoped to its campaign; a thread in another campaign yields ErrNotFound, never a permission error.

func (*Store) GetProviderConfig

func (s *Store) GetProviderConfig(ctx context.Context, id uuid.UUID) (ProviderConfig, error)

GetProviderConfig loads one Provider Config by id.

func (*Store) GetProviderConfigByComponent

func (s *Store) GetProviderConfigByComponent(ctx context.Context, tenantID uuid.UUID, component Component) (ProviderConfig, error)

GetProviderConfigByComponent returns the Tenant's most-recently-updated Provider Config for a Component, or ErrNotFound when none is bound. A Component can have more than one Provider in the matrix (ADR-0004); this resolves the one the operator last saved.

func (*Store) GetTenant added in v0.3.0

func (s *Store) GetTenant(ctx context.Context, id uuid.UUID) (Tenant, error)

GetTenant returns the Tenant with the given id, or ErrNotFound. The AuthService's GetCurrentUser uses it to serve the bound Tenant's display name (ADR-0055).

func (*Store) GetTenantIDByGuildID added in v0.4.0

func (s *Store) GetTenantIDByGuildID(ctx context.Context, guildID string) (uuid.UUID, error)

GetTenantIDByGuildID resolves a Discord Guild snowflake to the Tenant that configured it — the interaction→Tenant routing read (#490): an inbound slash interaction carries its Guild, and this maps it to the owning Tenant before any storage read touches campaigns, so a command only ever reaches its own Tenant's data. An unknown Guild returns ErrNotFound, which the Gate maps to a clean ephemeral rejection.

Since #483 a guild_id is bound by at most ONE Tenant (the first-registrar-wins partial UNIQUE index deployment_config_guild_owner; SaveDiscordChannels rejects a second Tenant's bind with ErrGuildTaken), so this read is unambiguous by construction for every Guild→Tenant consumer — the interaction router here AND the member-picker path (presence.Clients.VoiceChannelMembers). The ORDER BY is kept as harmless belt-and-braces against a pre-index legacy duplicate.

func (*Store) GetTenantSpendCaps

func (s *Store) GetTenantSpendCaps(ctx context.Context, tenantID uuid.UUID) (SpendCaps, error)

GetTenantSpendCaps loads a Tenant's soft/hard spend caps, each nil when that cap is unset. ErrNotFound when no tenant row exists for the id — distinct from a tenant that exists with both caps NULL (which returns a zero-value SpendCaps).

func (*Store) GetUserByDiscordID

func (s *Store) GetUserByDiscordID(ctx context.Context, discordUserID string) (User, error)

GetUserByDiscordID loads a user by Discord snowflake, or ErrNotFound.

func (*Store) GetVoiceSession

func (s *Store) GetVoiceSession(ctx context.Context, id uuid.UUID) (VoiceSession, error)

GetVoiceSession loads one Voice Session by id, or ErrNotFound.

func (*Store) GetVoiceSessionControl added in v0.4.0

func (s *Store) GetVoiceSessionControl(ctx context.Context, id uuid.UUID) (VoiceSessionControl, error)

GetVoiceSessionControl loads one control row by id, or ErrNotFound — the requester's poll read.

func (*Store) GetVoiceSessionIntent added in v0.4.0

func (s *Store) GetVoiceSessionIntent(ctx context.Context, id uuid.UUID) (VoiceSessionIntent, error)

GetVoiceSessionIntent loads one intent by id, or ErrNotFound. It backs the IntentControl poll (the web tier watching a Start it wrote) and tests.

func (*Store) HasPresenceOwner added in v0.4.0

func (s *Store) HasPresenceOwner(ctx context.Context) (bool, error)

HasPresenceOwner reports whether ANY presence-owner claim row exists — live or stale. It backs the -mode all mixed-deployment boot guard (#483 M3): an owner row (even an expired one) is proof a claim-plane voice fleet has been driving this database, and an all-mode process must refuse to join it (its broad boot reconcile would close live workers' rows, and its intent-less sessions break the one-live-per-tenant invariant).

func (*Store) HeartbeatVoiceSessionIntent added in v0.4.0

func (s *Store) HeartbeatVoiceSessionIntent(ctx context.Context, id uuid.UUID, instanceID string) (stopRequested bool, err error)

HeartbeatVoiceSessionIntent stamps heartbeat_at = now() for instanceID's live (or claimed) intent and reports whether a stop was requested, so the owning worker learns on each beat whether the web tier asked it to wind down. It is fenced WHERE instance_id=$2 AND status IN ('claimed','live'): a row the reaper already marked dead (worker declared stale), or one now owned by another instance, matches nothing and yields ErrNotFound — the caller reads that as "my claim was superseded" and kills its local session (ADR-0006: it must not keep running a session the plane believes is dead).

func (*Store) HighlightsExist added in v0.2.1

func (s *Store) HighlightsExist(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error)

HighlightsExist reports which of the given Highlight ids still have a row, returning a set (present ids map to true; absent ids are simply not in the map). It is the membership half of the boot orphan-image sweep's anti-join (#421): the sweep enumerates image blobs THROUGH the blob seam (blob.Store.List), extracts each blob's Highlight id, then asks this which of them still exist — the blobs whose id is absent are the delete-vs-enrich orphans. Keeping the anti-join in Go (not a SELECT against the blob table) is what keeps the orphan sweep working across a blob-backend swap (ADR-0048). An empty input runs no query. Process-wide, carries no tenant (the ids are globally unique PKs).

func (*Store) ImportVoiceSession

func (s *Store) ImportVoiceSession(ctx context.Context, v VoiceSession) (uuid.UUID, error)

ImportVoiceSession inserts a historical Voice Session from a Campaign Bundle (#292, ADR-0053): unlike [CreateVoiceSession], which mints a fresh 'running' row with started_at=now(), this writes the bundle's started_at, ended_at, status, line_count and end_reason VERBATIM and returns the freshly minted id. The caller (bundle.Import) guarantees a terminal status (a non-terminal one is coerced to 'ended' upstream) and a non-nil ended_at, so no live loop ever owns an imported row. end_reason is written NULL when nil. It runs inside the import transaction, so a later failure rolls the row back with the rest.

func (*Store) InTx

func (s *Store) InTx(ctx context.Context, fn func(*Store) error) error

InTx runs fn against a Store bound to a single transaction, committing if fn returns nil and rolling back otherwise. Used by multi-row operations that must be atomic (e.g. the live-NPC seed).

On a Store ALREADY bound to a transaction (created by an enclosing InTx), it FLATTENS: fn runs against the same tx-bound Store, with no nested Begin and no savepoint (#291). This lets a method that uses InTx internally (e.g. CreateEdge) compose inside a larger import transaction. The caveat is that the inner call's atomicity then becomes the OUTER transaction's: an error raised after such an inner "commit" still rolls the whole outer tx back — there is no independent inner rollback boundary. That is exactly what the bundle importer wants (one all-or-nothing import), but a caller relying on partial-commit semantics from a nested InTx would be surprised.

func (*Store) InsertTranscriptChunk

func (s *Store) InsertTranscriptChunk(ctx context.Context, c TranscriptChunk) (uuid.UUID, error)

InsertTranscriptChunk writes one closed chunk and returns its generated id. The embedding is left NULL — the async pipeline fills it later (ADR-0011) — and the arrays default to empty (never NULL). embedding_model is left at its empty column default; the backfill worker (#116) stamps it when it embeds the row.

func (*Store) IsCampaignLiveIntent added in v0.4.0

func (s *Store) IsCampaignLiveIntent(ctx context.Context, campaignID uuid.UUID) (bool, error)

IsCampaignLiveIntent reports whether campaignID has any non-terminal (pending/claimed/live) intent — the split-mode archive/delete live-guard (#491): the web tier drives no in-process session, so "is this Campaign live" is a claim-plane read rather than a Manager scan. Correct across the worker pool.

func (*Store) LastSpokenByAgent added in v0.5.0

func (s *Store) LastSpokenByAgent(ctx context.Context, campaignID uuid.UUID) ([]AgentLastSpoke, error)

LastSpokenByAgent returns, per Agent speaker label, the timestamp of its most recent committed Transcript Line in a Campaign (#544) — the "last spoke" prep signal. One grouped read for the whole roster rather than one per NPC.

Only Agent turns are considered (kind ∈ npc, butler); player and GM lines are a different question. Only committed Lines exist in this table at all (ADR-0012 / ADR-0040: partials are never persisted), so the answer is always about speech that actually reached the table.

It rides transcript_line_campaign_kind_idx (migration 00045). Without it this is a sequential scan of every tenant's transcript — the table had no campaign_id index because, until this read, nothing filtered on it.

func (*Store) ListAgents

func (s *Store) ListAgents(ctx context.Context, campaignID uuid.UUID) ([]Agent, error)

ListAgents returns all Agents in a Campaign (Butler + Character NPCs).

func (*Store) ListAllCampaigns

func (s *Store) ListAllCampaigns(ctx context.Context) ([]Campaign, error)

ListAllCampaigns returns every Campaign — active AND archived — ordered by name (then id for a stable tie-break). It backs the archive-management panel's include_archived read (#269); the default list surfaces (ListCampaigns, the /glyphoxa use autocomplete) stay archive-excluding. It is the tenant-FREE list the standalone voice node uses (ADR-0039 single-operator); the web/RPC tier uses Store.ListAllCampaignsInTenant so the panel shows only the caller's own campaigns (#473).

func (*Store) ListAllCampaignsInTenant added in v0.3.0

func (s *Store) ListAllCampaignsInTenant(ctx context.Context, tenantID uuid.UUID) ([]Campaign, error)

ListAllCampaignsInTenant is the tenant-scoped archive-INCLUSIVE list (#473): the archive-management panel's read for the web/RPC tier, scoped to the caller's tenant so it never surfaces another tenant's archived campaigns.

func (*Store) ListBoards added in v0.5.0

func (s *Store) ListBoards(ctx context.Context, campaignID uuid.UUID) ([]KGBoard, error)

ListBoards returns a Campaign's boards with their entries, in board order. One read for the whole set: a campaign has a handful of boards, and the Session screen wants them all at once.

func (*Store) ListCampaignHighlightClipKeys

func (s *Store) ListCampaignHighlightClipKeys(ctx context.Context, campaignID uuid.UUID) ([]string, error)

ListCampaignHighlightClipKeys returns EVERY blob key a campaign hard-delete must sweep through the seam BEFORE the row cascade removes the highlights (ADR-0048): each highlight's clip_key AND its image_key (#311) AND its sound_key (#312) when non-empty — a fully enriched Highlight owns three blobs. No status filter: a campaign delete takes its highlights with it, kept or not. clip_key is always present; image_key and sound_key are UNION ALL'd only where set, so unenriched rows contribute one key.

func (*Store) ListCampaignMapBlobKeys added in v0.5.0

func (s *Store) ListCampaignMapBlobKeys(ctx context.Context, campaignID uuid.UUID) ([]string, error)

ListCampaignMapBlobKeys returns every Map image key a Campaign owns, for the hard-delete blob sweep (ADR-0048).

campaign_map cascades with the campaign row, so the keys are unlistable the instant the delete commits — they MUST be captured first, exactly as the Highlight clip keys are. Without this the images survive the campaign forever with nothing left in the database that names them: a delete that frees the rows and silently keeps the bytes, which is the failure ADR-0048's lifecycle rule exists to prevent.

func (*Store) ListCampaignPortraitKeys added in v0.8.0

func (s *Store) ListCampaignPortraitKeys(ctx context.Context, campaignID uuid.UUID) ([]string, error)

ListCampaignPortraitKeys returns every non-empty portrait blob key in a Campaign — the campaign hard delete's sweep input (#590), mirroring ListCampaignMapBlobKeys: captured BEFORE the delete, because the row cascade removes the only records that name the keys (ADR-0048).

func (*Store) ListCampaigns

func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error)

ListCampaigns returns every ACTIVE Campaign ordered by name (then id for a stable tie-break) — the /glyphoxa use autocomplete source (#108). Archived campaigns are excluded (#269): the autocomplete inherits that filter with no code change of its own. It is the tenant-FREE list the standalone voice node's slash surface uses (ADR-0039 single-operator); the web/RPC tier uses Store.ListCampaignsInTenant so the picker shows only the caller's own campaigns (#473). See ListAllCampaigns for the archive-inclusive read.

func (*Store) ListCampaignsInTenant added in v0.3.0

func (s *Store) ListCampaignsInTenant(ctx context.Context, tenantID uuid.UUID) ([]Campaign, error)

ListCampaignsInTenant is the tenant-scoped ACTIVE-only list (#473): the web/RPC picker source, showing only the caller's own campaigns (`WHERE tenant_id = $1`).

func (*Store) ListCharacters

func (s *Store) ListCharacters(ctx context.Context, campaignID uuid.UUID) ([]Character, error)

ListCharacters returns every Player Character in a Campaign in a stable display order (case-insensitive name, then id). An empty result is not an error.

func (*Store) ListDeploymentConfigs added in v0.4.0

func (s *Store) ListDeploymentConfigs(ctx context.Context) ([]DeploymentConfig, error)

ListDeploymentConfigs returns every saved deployment config, one per Tenant, ordered oldest-updated first for a deterministic boot seed. The per-tenant Discord client registry (#489, ADR-0010) reads this once at boot — before any request, with no tenant context — to stand up one standing client per distinct Bot token; each request-path read still narrows to a single Tenant via GetDeploymentConfig. An empty table returns a nil slice, not an error.

func (*Store) ListEdges

func (s *Store) ListEdges(ctx context.Context, campaignID uuid.UUID) ([]KGEdge, error)

ListEdges returns every Edge in a Campaign ordered (created_at, id) — the deterministic read the Campaign Bundle exporter serialises (#288, ADR-0053). It is Campaign-scoped (#342): only rows whose campaign_id matches are returned, so no Edge leaks across Campaigns. An empty Campaign yields an empty slice, not an error. Export is a rare admin read, so this reuses the (campaign_id) filter without a dedicated index (ADR-0053 decision).

func (*Store) ListGraphNodes added in v0.5.0

func (s *Store) ListGraphNodes(ctx context.Context, campaignID uuid.UUID) ([]KGGraphNode, error)

ListGraphNodes returns every Node in a Campaign in the display order ListNodes uses (type, case-insensitive name, id), projected for the graph view. The order is stable and content-independent, which is what lets the client's layout be a pure function of the payload.

This is a GM-FACING read: gm_private Nodes are included, because the Graph view is the GM's map of their own world. It is deliberately NOT part of PromptKGView — prompt assembly cannot reach it (#450).

func (*Store) ListHighlights

func (s *Store) ListHighlights(ctx context.Context, tenantID, voiceSessionID uuid.UUID) ([]Highlight, error)

ListHighlights returns a Voice Session's highlights within the tenant, newest moment first (starts_at DESC, id DESC for a stable tie-break). It backs the GM session-end review UI (#309). An empty result is not an error.

func (*Store) ListMaps added in v0.5.0

func (s *Store) ListMaps(ctx context.Context, campaignID uuid.UUID) ([]CampaignMap, error)

ListMaps returns a Campaign's Maps in a stable display order (name, id). GM-facing: gm_private Maps are INCLUDED. The player-tier read is Store.ListPlayerMaps.

func (*Store) ListNodeAppearances added in v0.5.0

func (s *Store) ListNodeAppearances(ctx context.Context, campaignID, nodeID uuid.UUID, limit int) ([]AppearanceHit, error)

ListNodeAppearances returns an entry's most recent appearances, newest first.

The join is what makes the list useful: each row carries the line's own text and speaker, so the GM reads WHAT was said without following the link, and follows it only when they want the surrounding scene.

func (*Store) ListNodeAspects added in v0.5.0

func (s *Store) ListNodeAspects(ctx context.Context, campaignID, nodeID uuid.UUID) ([]KGNodeAspect, error)

ListNodeAspects returns one Node's Aspects in author order, INCLUDING private ones — a GM-facing read (the editor, the Campaign Bundle export). Prompt-facing code never calls it: it reads Aspects through the Node projections, whose aggregate filters gm_private in SQL.

func (*Store) ListNodes

func (s *Store) ListNodes(ctx context.Context, campaignID uuid.UUID) ([]KGNode, error)

ListNodes returns every Knowledge Graph Node in a Campaign in a stable display order (node_type enum order, then case-insensitive name, then id) — the Knowledge panel's list. Each Node carries its Aspects, private ones INCLUDED: this is a GM-facing read and never flows into prompt assembly (#450, #542). An empty result is not an error.

func (*Store) ListPendingKnowledgeProposals

func (s *Store) ListPendingKnowledgeProposals(ctx context.Context, campaignID uuid.UUID) ([]KnowledgeProposal, error)

ListPendingKnowledgeProposals returns a Campaign's pending proposals oldest-first (the review order), hitting the partial pending index. An empty queue yields an empty slice, not an error.

func (*Store) ListPendingVoiceSessionControls added in v0.4.0

func (s *Store) ListPendingVoiceSessionControls(ctx context.Context, intentID uuid.UUID) ([]VoiceSessionControl, error)

ListPendingVoiceSessionControls returns an intent's pending control rows in (created_at, id) order — the hosting worker's per-heartbeat drain scan. The order matters for 'say' (utterances must land in request order); mutes are idempotent so the queue is harmless for them.

func (*Store) ListPins added in v0.5.0

func (s *Store) ListPins(ctx context.Context, campaignID, mapID uuid.UUID) ([]MapPin, error)

ListPins returns a Map's Pins in a stable order. GM-facing: every Pin is included, whatever its own or its Node's privacy. Store.ListPlayerPins is the player-tier read.

func (*Store) ListPlanningMessages added in v0.8.0

func (s *Store) ListPlanningMessages(ctx context.Context, campaignID, threadID uuid.UUID) ([]PlanningMessage, error)

ListPlanningMessages returns a thread's messages in seq order. The thread's existence is not checked here — an unknown thread yields an empty slice; the caller resolves the thread first (GetPlanningThread) when it matters.

func (*Store) ListPlanningThreads added in v0.8.0

func (s *Store) ListPlanningThreads(ctx context.Context, campaignID uuid.UUID) ([]PlanningThread, error)

ListPlanningThreads returns the campaign's threads, most recently touched first (an exchange bumps updated_at), id tie-broken for a stable order.

func (*Store) ListPlans added in v0.2.1

func (s *Store) ListPlans(ctx context.Context) ([]Plan, error)

ListPlans returns the full catalog (archived included, flagged), stable-ordered by slug.

func (*Store) ListPlayerMaps added in v0.5.0

func (s *Store) ListPlayerMaps(ctx context.Context, campaignID uuid.UUID) ([]CampaignMap, error)

ListPlayerMaps is the player-tier read (ADR-0056): gm_private Maps are excluded in the QUERY, so they are absent from the response rather than merely hidden by the UI.

func (*Store) ListPlayerPins added in v0.5.0

func (s *Store) ListPlayerPins(ctx context.Context, campaignID, mapID uuid.UUID) ([]MapPin, error)

ListPlayerPins is the player-tier read (ADR-0056): a Pin is excluded IN THE QUERY when it is gm_private OR its Node is. A position that points at a GM secret is itself a leak — knowing "something is here" and what it is called is most of the secret — so the Node's flag propagates, mirroring ADR-0008's rule that gm_private filtering applies to expansion and not only to direct reads.

func (*Store) ListPromotedHighlightsNeedingEnrichment added in v0.2.1

func (s *Store) ListPromotedHighlightsNeedingEnrichment(ctx context.Context, enrichKind string) ([]HighlightEnrichTarget, error)

ListPromotedHighlightsNeedingEnrichment returns every PROMOTED Highlight with an empty image_key and NO enrich job of the given kind in a live state (pending/running/done) — the promoted Highlights whose image enrichment was never enqueued (a crash between promote-commit and the enqueue) or whose only enqueue was lost (#406). It is the (a) half of the boot reconciliation sweep, mirroring ListSessionsNeedingCandidatePurge. A 'done' job counts as satisfied so an unconfigured/failed-permanent enrichment (the handler returns nil and leaves the row imageless by design) is NOT re-swept every boot; 'dead' is treated as absent so a genuinely dead-lettered enrichment is re-scheduled. A job is matched on its payload's highlight_id. Process-wide, carries no tenant.

func (*Store) ListPromotedHighlightsNeedingSoundEnrichment added in v0.10.0

func (s *Store) ListPromotedHighlightsNeedingSoundEnrichment(ctx context.Context, enrichKind string) ([]HighlightSoundEnrichTarget, error)

ListPromotedHighlightsNeedingSoundEnrichment returns every PROMOTED Highlight with a requested-but-unlanded sound (sound_kind set, sound_key empty) and NO enrich job of the given kind in a live state matching BOTH the highlight AND the requested sound kind (#312). Matching the payload's kind too is what lets a choice CHANGE re-sweep: a 'done' sting job must not satisfy a later music request. 'done' counts as satisfied (an unconfigured no-op is not re-swept every boot); 'dead' is treated as absent so a dead-lettered generation is re-scheduled once the key is fixed. Process-wide, carries no tenant.

func (*Store) ListProviderConfigs

func (s *Store) ListProviderConfigs(ctx context.Context, tenantID uuid.UUID) ([]ProviderConfig, error)

ListProviderConfigs returns all of a Tenant's Provider Configs, ordered by Component then Provider (deterministic for the Configuration screen, #68). An empty result is not an error.

func (*Store) ListSessionAppearances added in v0.5.0

func (s *Store) ListSessionAppearances(ctx context.Context, sessionID uuid.UUID) ([]SessionAppearance, error)

ListSessionAppearances returns every appearance recorded in one Voice Session, for the history-flagged bundle export.

func (*Store) ListSessionCandidateClipKeys

func (s *Store) ListSessionCandidateClipKeys(ctx context.Context, voiceSessionID uuid.UUID) ([]string, error)

ListSessionCandidateClipKeys returns the clip_key of every remaining CANDIDATE highlight for a Voice Session — the blob keys the 7-day purge job drops through the seam BEFORE it deletes the rows (blob-first, ADR-0048). Promoted rows are excluded (they are kept). Session-scoped (the purge payload carries no tenant).

func (*Store) ListSessionsNeedingCandidatePurge

func (s *Store) ListSessionsNeedingCandidatePurge(ctx context.Context, purgeKind string) ([]SessionPurgeCandidate, error)

ListSessionsNeedingCandidatePurge returns the ENDED Voice Sessions (id + ended_at) that still hold at least one 'candidate' highlight but have NO purge job of the given kind in a live state (pending/running/done) — the sessions whose 7-day purge was never scheduled because a crash landed between the session ending and the Saver's Finalize enqueue (#308, ADR-0051). It is the input to the boot-time backstop sweep. ended_at is returned so the sweep anchors the horizon at session END (ended_at+7d), not boot time. A job is matched on its payload's voice_session_id (the purge payload's only field); 'dead' jobs are treated as absent so a permanently-failed purge is re-scheduled. Session-scoped, carries no tenant (the sweep is process-wide, ADR-0049).

func (*Store) ListTapeConsent

func (s *Store) ListTapeConsent(ctx context.Context, campaignID uuid.UUID) ([]string, error)

ListTapeConsent returns the Discord user ids that have consented to the rollover tape for a Campaign (#306), the set the tape is seeded with when a Voice Session arms it. Order is stable by created_at for deterministic reads.

func (*Store) ListTenantOperatorBindings added in v0.4.0

func (s *Store) ListTenantOperatorBindings(ctx context.Context) ([]TenantOperatorBinding, error)

ListTenantOperatorBindings returns each Tenant paired with its operator's Discord snowflake (tenant.operator_user_id → users.discord_user_id) — the per-Tenant GM-identity source (#490, ADR-0055). It is the tenant-scoped analogue of ListTenantOperatorDiscordIDs and shares its exclusions: the synthetic dev operator (DevOperatorDiscordID) never appears (it can match no real speaker/interaction user) and a SUSPENDED operator drops out (open-mode revocation reaches GM on the next snapshot refresh). Sorted for determinism. An empty result is not an error.

func (*Store) ListTenantOperatorDiscordIDs added in v0.3.0

func (s *Store) ListTenantOperatorDiscordIDs(ctx context.Context) ([]string, error)

ListTenantOperatorDiscordIDs returns the DISTINCT Discord snowflakes bound as tenant operators (tenant.operator_user_id → users.discord_user_id), sorted for determinism — the interim GM-identity source (ADR-0055, amending ADR-0050's allowlist-membership clause). auth.GMIdentity snapshots it; unbound tenants and unbound users simply don't appear (the env-allowlist fallback covers the NULL-binding migration edge). The synthetic dev operator (DevOperatorDiscordID) is excluded: it can never match a real Discord speaker or interaction user, so on a dev-touched DB it would only make the GM set look non-empty — suppressing the "Butler unaddressable" boot warning — while admitting nobody. An empty result is not an error.

func (*Store) ListTenantsWithPlan added in v0.2.1

func (s *Store) ListTenantsWithPlan(ctx context.Context) ([]TenantPlanRow, error)

ListTenantsWithPlan lists every tenant with its active plan slug (empty when unsubscribed) — the operator's `billing tenants` view for finding tenant ids.

func (*Store) ListToolGrants

func (s *Store) ListToolGrants(ctx context.Context, agentID uuid.UUID) ([]ToolGrant, error)

ListToolGrants returns an Agent's VOICE Tool Grants ordered by tool_name (a stable order so a hydrated GrantSet — and thus the ADR-0021 prompt_hash — does not thrash between runs). An Agent with no grants yields an empty slice, not an error: least-privilege means such an Agent is shown no Tool at all. Voice-only is the pre-0062 meaning every existing caller (wirenpc, the grant editor RPC, the bundle) relies on; the chat surface reads its disjoint rows via Store.ListToolGrantsFor.

func (*Store) ListToolGrantsFor added in v0.8.0

func (s *Store) ListToolGrantsFor(ctx context.Context, agentID uuid.UUID, surface GrantSurface) ([]ToolGrant, error)

ListToolGrantsFor returns an Agent's Tool Grants for one surface (#592, ADR-0062), ordered by tool_name. The surfaces are disjoint row sets, so a chat hydration can never arm a voice-granted tool and vice versa.

func (*Store) ListTranscriptChunks

func (s *Store) ListTranscriptChunks(ctx context.Context, campaignID uuid.UUID, includeVectors bool) ([]ExportChunk, error)

ListTranscriptChunks returns every Transcript Chunk in a Campaign ordered (created_at, id) — the deterministic read the Campaign Bundle exporter serialises (#288, ADR-0053). It is Campaign-scoped (#342): only matching campaign_id rows are returned, so no Chunk leaks across Campaigns. embedding_model is always selected; the vector is included as its ::text form only when includeVectors is true, else "" (COALESCE handles NULL either way). The exporter passes false (ADR-0053 d3 strips vectors); the flag exists for the backup/migration path. Vectors stay text-form so storage keeps no pgvector-go dependency.

func (*Store) ListTranscriptLines

func (s *Store) ListTranscriptLines(ctx context.Context, sessionID uuid.UUID) ([]TranscriptLine, error)

ListTranscriptLines returns a Voice Session's transcript Lines ordered by seq — the replay-on-reload history the Session screen renders for an ended session (#74). An empty result is not an error.

func (*Store) ListUnembeddedChunks

func (s *Store) ListUnembeddedChunks(ctx context.Context, limit int) ([]TranscriptChunk, error)

ListUnembeddedChunks returns up to limit Transcript Chunks still awaiting an embedding (embedding IS NULL), oldest first — the backfill worker's work queue (#116, ADR-0011). Ordering by (created_at, id) makes each pass drain the oldest backlog first and be a stable, deterministic batch. An empty result is not an error: it means the backlog is drained and the worker sleeps.

func (*Store) ListUnembeddedNodes added in v0.2.1

func (s *Store) ListUnembeddedNodes(ctx context.Context, limit int) ([]KGNode, error)

ListUnembeddedNodes returns up to limit Knowledge Graph Nodes still awaiting an embedding (embedding IS NULL), oldest first — the node half of the embedworker backfill queue (#300, ADR-0011), mirroring ListUnembeddedChunks. An empty result means the backlog is drained. The name + aspects + body is embedded by the worker, so the projection carries the Aspects — private ones included: the embedding feeds the GM-facing similarity hints (ADR-0052), which are never prompt-facing, and a secret that is invisible to the vector would make duplicate detection blind to exactly the facts GMs most want deduped.

func (*Store) ListVoiceSessions

func (s *Store) ListVoiceSessions(ctx context.Context, campaignID uuid.UUID, limit int) ([]VoiceSession, error)

ListVoiceSessions returns a Campaign's Voice Sessions newest-first (started_at DESC, id DESC — the same tiebreak as GetLatestVoiceSession), the running row included, capped at limit. It backs the Session screen's past-session picker (#270): the operator picks a prior session to replay its persisted transcript. It reuses voiceSessionColumns/scanVoiceSession and is served by voice_sessions_campaign_idx (no migration). An empty result is not an error (the never-run picker state).

func (*Store) LoadAgent

func (s *Store) LoadAgent(ctx context.Context, id uuid.UUID) (LoadedAgent, error)

LoadAgent loads an Agent together with its bound LLM and TTS Provider Configs — the Persona/Voice/provider bundle the orchestrator needs (the core read this task exists to enable). Missing or unbound configs yield nil, not an error; only the Agent itself must exist.

func (*Store) MapAncestors added in v0.5.0

func (s *Store) MapAncestors(ctx context.Context, campaignID, id uuid.UUID) ([]CampaignMap, error)

MapAncestors returns a Map's ancestor chain, nearest parent first, for the breadcrumb. It is bounded: a cycle (which the schema permits, since a self-reference is only blocked at depth 1) terminates at maxMapDepth rather than looping, so a mis-parented Map degrades to a truncated breadcrumb instead of hanging the screen.

func (*Store) MapSeedContext added in v0.5.0

func (s *Store) MapSeedContext(ctx context.Context, campaignID, nodeID uuid.UUID) (KGNode, []string, error)

MapSeedContext returns the PUBLIC material a generated map's prompt may be seeded from (#541): the anchor Location itself, and the names of what resides in it.

Public-only, filtered in the QUERY, on the same seam-not-call-site principle PromptKG follows — and for a sharper reason than usual. A generated map is an artefact the GM shows the table. Seeding its prompt from gm_private prose, or from the name of a gm_private neighbour, launders a secret into a picture, and unlike a prompt a picture cannot be filtered afterwards or un-seen. Composing this from ListNodes + NodeEdges would have done exactly that: both are GM-facing reads that deliberately include private rows.

A gm_private anchor yields ErrNotFound. That is not an oversight: a secret place has no public depiction to generate.

func (*Store) MarkJobDead

func (s *Store) MarkJobDead(ctx context.Context, id uuid.UUID, attempts int, lastError string) error

MarkJobDead moves the caller's claimed generation of a job to the dead-letter state with a recorded last_error and a cleared lease — visible, never silently retried again. attempts is the claim generation. A missing id OR a superseded claim yields ErrNotFound.

func (*Store) MarkVoiceSessionIntentLive added in v0.4.0

func (s *Store) MarkVoiceSessionIntentLive(ctx context.Context, id uuid.UUID, instanceID string, voiceSessionID uuid.UUID) (VoiceSessionIntent, error)

MarkVoiceSessionIntentLive flips instanceID's claimed intent to 'live' and binds the voice_sessions row the worker created, stamping a fresh heartbeat. It is fenced by (id, instance_id, status='claimed'): a superseded caller (a reaper already marked it dead, or a different instance owns it) matches no row and yields ErrNotFound.

func (*Store) NodeEdges

func (s *Store) NodeEdges(ctx context.Context, campaignID, nodeID uuid.UUID) (outgoing, incoming []KGEdgeWithNodes, err error)

NodeEdges returns a Node's incident Edges split by direction — outgoing (from_node_id = nodeID) and incoming (to_node_id = nodeID) — each joined to both endpoints' name/type so the Campaign screen renders without an N+1. One query fetches both directions (ordered created_at, id); the split is done in Go.

The read is scoped to the owning Campaign (#356): the anchor Node must belong to campaignID, else ErrNotFound — a Node in another Campaign is invisible, leaking neither its edges nor its joined endpoint names (incl. gm_private ones) nor an existence oracle. A truly-missing Node id is the same ErrNotFound. Same no-oracle discipline as the cross-campaign DeleteEdge/SetNodeAgent refusals.

func (*Store) NodePins added in v0.5.0

func (s *Store) NodePins(ctx context.Context, campaignID, nodeID uuid.UUID) ([]MapPin, error)

NodePins returns every Pin for one Node across all Maps in the Campaign — "where is this?", answered from the entry rather than from a Map. It is what makes a Node's position discoverable without opening every Map in turn.

GM-facing: gm_private Pins, Pins on gm_private Maps and Pins on gm_private Nodes are ALL included. Anything prompt- or player-facing must use Store.PlayerNodePins — the two are separate functions rather than one with a flag the caller might forget, because forgetting here means a Character NPC telling the table where the GM's secret is.

func (*Store) NodePortrait added in v0.8.0

func (s *Store) NodePortrait(ctx context.Context, campaignID, nodeID uuid.UUID) (string, time.Time, error)

NodePortrait returns a Node's portrait blob key (” when it has none) and its updated_at — exactly what the plain-HTTP portrait serve needs (#590): the key to fetch and the cache validator to serve with. Campaign-scoped; a Node outside the campaign is ErrNotFound.

func (*Store) NodeTags added in v0.5.0

func (s *Store) NodeTags(ctx context.Context, campaignID, nodeID uuid.UUID) ([]string, error)

NodeTags returns one Node's tags, alphabetically.

func (*Store) PinsNear added in v0.5.0

func (s *Store) PinsNear(ctx context.Context, campaignID, mapID uuid.UUID, x, y, radius float64, publicOnly bool, limit int) ([]MapPin, error)

PinsNear returns the Pins within `radius` (in normalized map units) of a point on a Map, nearest first, excluding any Pin at the exact origin — the spatial Tool's "what is around us" read (#539).

publicOnly applies the same composed visibility as Store.ListPlayerPins: a Pin is excluded when it or its Node is gm_private. Every PROMPT-facing caller passes true, because a spatial answer that names a GM secret leaks it just as surely as a fact would.

Distance is Euclidean in normalized space, so it is an aspect-ratio-agnostic approximation of real proximity — good enough for "what is near us", and honest about not being a survey.

func (*Store) PlayerNodePins added in v0.5.0

func (s *Store) PlayerNodePins(ctx context.Context, campaignID, nodeID uuid.UUID) ([]MapPin, error)

PlayerNodePins is Store.NodePins with the privacy filter pushed into SQL: a gm_private Pin, a Pin whose Node is gm_private, and a Pin on a gm_private Map are all invisible. It is the read the prompt-facing spatial Tools consult (#539), so a GM secret cannot reach an NPC's answer even by mistake.

func (*Store) PortraitSeedContext added in v0.8.0

func (s *Store) PortraitSeedContext(ctx context.Context, campaignID, nodeID uuid.UUID) (KGNode, error)

PortraitSeedContext returns the PUBLIC material a generated portrait's prompt may be seeded from (#590): the Node itself with its public Aspects only.

Public-only, filtered in the QUERY, on the same seam-not-call-site principle MapSeedContext follows, and for the same reason: a portrait is an artefact the GM shows the table, so seeding its prompt from gm_private prose or facts launders a secret into a picture that cannot be filtered afterwards.

A gm_private Node yields ErrNotFound — a secret character has no public depiction to generate. The GM can still upload a portrait for one by hand.

func (*Store) PromoteHighlight

func (s *Store) PromoteHighlight(ctx context.Context, tenantID, id uuid.UUID) (Highlight, error)

PromoteHighlight flips a candidate to 'promoted' and stamps promoted_at within the tenant, returning the updated row (#309 GM keep). It is idempotent: a re-promote keeps the ORIGINAL promoted_at (COALESCE) so the audit trail of WHEN the GM first kept it survives. A missing id (or foreign tenant) yields ErrNotFound. It deliberately does NOT enqueue enrichment — that is #311's hook.

func (*Store) PromptKG added in v0.2.1

func (s *Store) PromptKG() PromptKGView

PromptKG returns the prompt-facing read view of this Store — the ONLY KG handle prompt-assembly wiring should be given (#450).

func (*Store) ProvisionSignup added in v0.3.0

func (s *Store) ProvisionSignup(ctx context.Context, p SignupParams) (SignupResult, error)

ProvisionSignup admits an open-mode signup in ONE transaction: Discord user upsert → bound-tenant lookup → (when none) create-only Tenant founding + default-Plan bind → session mint. All-or-nothing per ADR-0055: a failure at any step (most plausibly an unknown or archived default plan slug) leaves no user, tenant, subscription, or session behind. A returning signup — the user already bound to a Tenant — founds nothing and just refreshes identity + mints the session. A suspended user is refused with ErrUserSuspended.

func (*Store) ReapDeadVoiceSessionIntents added in v0.4.0

func (s *Store) ReapDeadVoiceSessionIntents(ctx context.Context, expiry time.Duration) (int64, error)

ReapDeadVoiceSessionIntents marks 'dead' every claimed/live intent whose heartbeat is older than expiry — the owning Voice Instance is presumed crashed (ADR-0006/0057 (e): no takeover, so a stale claim is a death, not a hand-off). The Tenant sees the dead state and can restart. A fresh heartbeat (within expiry) is untouched. Returns how many rows were reaped. Called once per claim loop tick before claiming, mirroring SweepExpiredJobs (ADR-0049).

func (*Store) ReapVoiceSessionIntentIfExpired added in v0.4.0

func (s *Store) ReapVoiceSessionIntentIfExpired(ctx context.Context, id uuid.UUID, expiry time.Duration) (bool, error)

ReapVoiceSessionIntentIfExpired marks ONE claimed/live intent dead when its heartbeat is older than expiry — the zero-worker escape (#491 review item 4): the reaper otherwise runs only inside a worker's claim tick, so with NO healthy worker a dead claimed/live intent would never expire and its Tenant would stay blocked (ErrIntentActive) forever. IntentControl.Start calls this on the exact row blocking a retry. Fenced by id + a stale heartbeat, so a live row (fresh beat) or a foreign status is untouched. Returns whether it reaped a row.

func (*Store) ReconcileOrphanedVoiceSessions

func (s *Store) ReconcileOrphanedVoiceSessions(ctx context.Context) (int64, error)

ReconcileOrphanedVoiceSessions closes every Voice Session row still marked 'running' — at startup no live loop exists, so any such row is an orphan from a crash or a failed end-write (#143). Each is stamped ended_at=now(), status='ended' and the distinguishing VoiceSessionReasonOrphaned end_reason (a clean end leaves end_reason NULL). Returns how many rows were closed. Called by the SessionManager at boot, before any session can start.

func (*Store) ReconcileWorkerOrphanedVoiceSessions added in v0.4.0

func (s *Store) ReconcileWorkerOrphanedVoiceSessions(ctx context.Context) (int64, error)

ReconcileWorkerOrphanedVoiceSessions closes 'running' voice_sessions rows that a -mode voice worker left behind on a crash — NEVER a row a live worker still owns (#491, the reviewer-flagged process-blindness): a plain ReconcileOrphanedVoiceSessions is process-blind, so two workers booting would close each other's live 'running' rows. Two worker-safe arms:

  1. Rows BOUND to a now-terminal intent (dead/done/failed) — the ordinary crashed-worker leftovers.
  2. Rows whose Campaign has NO non-terminal intent at all (#483 M2): a worker dying between CreateVoiceSession and MarkVoiceSessionIntentLive leaves a 'running' row its intent never bound (voice_session_id NULL), so the reaped 'dead' intent can never match arm 1 and the row would sit 'running' forever. No non-terminal intent for the Campaign means no worker can be mid-Start or live on it (the gap itself holds a 'claimed' intent, and a live session holds a 'live' one), so closing is safe. An unbound orphan whose Tenant has ALREADY restarted (a fresh non-terminal intent on the same Campaign) is deferred until that intent goes terminal — eventual, never wrong.

Returns how many rows were closed. The -mode all path keeps the broad [ReconcileOrphanedVoiceSessions] (it writes no intents; mixing the two shapes on one DB is refused at boot, see cmd/glyphoxa's mixed-mode guard).

func (*Store) RecordAdmissionPosture added in v0.3.0

func (s *Store) RecordAdmissionPosture(ctx context.Context, mode string) error

RecordAdmissionPosture upserts the singleton deployment_settings row with the effective Admission Mode, so the posture survives env-var loss and stays visible to operators (ADR-0055's rollback-trap mitigation).

func (*Store) RecordNodeAppearances added in v0.5.0

func (s *Store) RecordNodeAppearances(ctx context.Context, rows []NodeAppearance) error

RecordNodeAppearances writes a batch of appearances, ignoring ones already recorded.

ON CONFLICT DO NOTHING is what makes the indexing job safely re-runnable: a retried or replayed job re-derives the same (node, line) pairs and writes nothing new, so nobody has to reason about whether it already ran.

func (*Store) RejectKnowledgeProposal added in v0.2.1

func (s *Store) RejectKnowledgeProposal(ctx context.Context, campaignID, id uuid.UUID) error

RejectKnowledgeProposal drops a pending proposal (status rejected, reviewed_at stamped) WITHOUT touching the KG (#300, ADR-0052). The row is kept for audit. A missing/already-reviewed id — or one in another Campaign — yields ErrNotFound.

func (*Store) ReleaseDiscordGuild added in v0.4.0

func (s *Store) ReleaseDiscordGuild(ctx context.Context, tenantID uuid.UUID, guildID string) (DeploymentConfig, error)

ReleaseDiscordGuild frees a Tenant's bound guild (#504): an atomic compare-and-clear — the caller echoes the guild_id it believes is bound, and the row is cleared only when tenant AND guild match (no read-then-write race). guild_id = ” is the unconfigured state (migration 00037), so release needs no schema change and frees the first-registrar-wins index slot for the next binder (legit transfer: A releases, B saves with proof). The Bot token is untouched. No matching bound row — wrong echo, already released, or no config at all — returns ErrNotFound.

func (*Store) ReleaseHighlightEnrichClaim added in v0.2.1

func (s *Store) ReleaseHighlightEnrichClaim(ctx context.Context, id uuid.UUID) error

ReleaseHighlightEnrichClaim clears a Highlight's enrichment claim (#406) so a retry (or a later re-promotion) can re-claim without waiting out the ttl. It is idempotent — clearing an already-null claim is a no-op — and tenant-free.

func (*Store) ReleaseHighlightSoundEnrichClaim added in v0.10.0

func (s *Store) ReleaseHighlightSoundEnrichClaim(ctx context.Context, id uuid.UUID) error

ReleaseHighlightSoundEnrichClaim clears a Highlight's sound-generation claim (#312) so a retry (or a re-run of the Add-sound action) can re-claim without waiting out the ttl. Idempotent and tenant-free.

func (*Store) ReleasePresenceOwner added in v0.4.0

func (s *Store) ReleasePresenceOwner(ctx context.Context, instanceID string) error

ReleasePresenceOwner drops instanceID's presence-owner claim so a challenger's very next AcquireOrRenewPresenceOwner wins immediately (a clean drain handover, not an expiry wait). Fenced by instance_id: a superseded former owner that already lost the row deletes nothing, never the new owner's claim. Deleting no row is not an error.

func (*Store) RenameAgentNode added in v0.3.0

func (s *Store) RenameAgentNode(ctx context.Context, campaignID, agentID uuid.UUID, oldName, newName string) error

RenameAgentNode renames an Agent's linked NPC Node WHEN the two names still match (#479, ADR-0008 second amendment): after the auto-created Node, a GM renaming the Agent (typically off the "New NPC" placeholder) keeps the wiki entry in step — but once the GM has renamed the Node independently, the names diverge and the follow stops for good; bodies are never synced. The rename resets the embedding like any other name edit (ADR-0011) so similarity hints re-embed the new text. Matching no row (no linked Node, names already diverged, or oldName == newName) is a successful no-op.

func (*Store) RenameBoard added in v0.5.0

func (s *Store) RenameBoard(ctx context.Context, campaignID, id uuid.UUID, name string) error

RenameBoard renames a prep board.

func (*Store) RenamePlanningThread added in v0.8.0

func (s *Store) RenamePlanningThread(ctx context.Context, campaignID, id uuid.UUID, title string) (PlanningThread, error)

RenamePlanningThread sets a thread's title and returns the updated row. A thread outside the campaign yields ErrNotFound.

func (*Store) RenameTag added in v0.5.0

func (s *Store) RenameTag(ctx context.Context, campaignID uuid.UUID, from, to string) error

RenameTag renames a tag across the whole Campaign — the AC's "renaming a tag updates every use". Done in SQL rather than per-node, because a tag is one concept and renaming it entry-by-entry is how half-renamed vocabularies happen.

A node already carrying the target name keeps a single row: the ON CONFLICT drops the duplicate rather than failing the whole rename.

func (*Store) RenameTenant added in v0.3.0

func (s *Store) RenameTenant(ctx context.Context, id uuid.UUID, name string) (Tenant, error)

RenameTenant sets the Tenant's display name — the ADR-0055 onboarding step ("name your Tenant") for a freshly signed-up founder. Validation (non-empty, 200-character cap) lives at the RPC (AuthServer.RenameTenant); storage stays faithful. ErrNotFound for an unknown tenant.

func (*Store) ReplaceNodeAspects added in v0.5.0

func (s *Store) ReplaceNodeAspects(ctx context.Context, campaignID, nodeID uuid.UUID, w KGNodeAspectWrite) error

ReplaceNodeAspects rewrites a Node's Aspects to the supplied list, preserving rows the caller had not loaded and keeping row identity stable (see KGNodeAspectWrite), inside one transaction.

The write is scoped to (node_id, campaign_id) like every other KG mutation (#342): a Node in another Campaign matches nothing, so nothing is removed and the insert is refused by the composite FK.

An empty Rows list clears the Aspects the caller knew about (the Node keeps its free-form body). Exceeding kgvocab.MaxAspectsPerNode once survivors are counted yields ErrAspectsFull.

func (*Store) RequestVoiceSessionStop added in v0.4.0

func (s *Store) RequestVoiceSessionStop(ctx context.Context, id uuid.UUID) (VoiceSessionIntent, error)

RequestVoiceSessionStop asks the claim plane to wind an intent down. A still 'pending' intent (no worker has claimed it) is taken straight to 'done' with ended_at set — there is no worker to honor a flag, so the stop resolves immediately. A claimed/live intent instead sets stop_requested=true, which the owning worker sees on its next heartbeat and acts on. A missing id, or an already-terminal intent, yields ErrNotFound. Returns the updated row.

func (*Store) ResolveMapByName added in v0.5.0

func (s *Store) ResolveMapByName(ctx context.Context, campaignID uuid.UUID, name string) (CampaignMap, error)

ResolveMapByName finds a Map in a Campaign by case-insensitive name — what the /where slash command resolves its argument through. Exactly one match is required: zero or many are refused so the GM fixes the ambiguity rather than the party being silently teleported to whichever row sorted first.

func (*Store) ResolveOperatorTenant

func (s *Store) ResolveOperatorTenant(ctx context.Context, userID uuid.UUID) (Tenant, error)

ResolveOperatorTenant binds the single seeded Tenant to the first operator and returns it (ADR-0039). It is idempotent and atomic: if a tenant is already bound to the user it is returned; otherwise the earliest claimable tenant — unbound (the seed's) or held by the synthetic dev operator (ADR-0041 GLYPHOXA_DEV_MODE, see DevOperatorDiscordID) — is claimed; otherwise — a fresh DB with no seed — a new 'Glyphoxa' tenant is created bound to the user. Called once on OAuth login and by the dev-mode boot.

func (*Store) ResolvePinByLabel added in v0.5.0

func (s *Store) ResolvePinByLabel(ctx context.Context, campaignID, mapID uuid.UUID, label string) (MapPin, error)

ResolvePinByLabel finds a Pin on a Map by its displayed label (its override, or its Node's name), case-insensitively. Same one-match rule as ResolveMapByName, for the same reason.

func (*Store) RetryJob

func (s *Store) RetryJob(ctx context.Context, id uuid.UUID, attempts int, lastError string, runAfter time.Time) error

RetryJob returns the caller's claimed generation of a job to pending with a recorded last_error and a future run_after (the backoff delay), clearing its lease so it is re-claimable once run_after arrives. attempts is the claim generation. A missing id OR a superseded claim yields ErrNotFound.

func (*Store) RevokeSessionsOutsideAllowlist

func (s *Store) RevokeSessionsOutsideAllowlist(ctx context.Context, discordUserIDs []string) (int64, error)

RevokeSessionsOutsideAllowlist deletes every session whose owning user's discord_user_id is not on the operator allowlist (ADR-0041 amendment, issue #184). The allowlist gates only NEW logins at the OAuth callback; this sweep is the revocation half, run at every non-dev ALLOWLIST-Admission-Mode web/all boot — which is exactly when a grant change takes effect, since the env var is parsed at boot. The sweep splits by Admission Mode (ADR-0055): an `open` boot must never run it (it would log out every signup each restart — revocation there is suspension-based), and flipping open -> allowlist plus a restart evicts every signup, the deliberate lock-down. It also clears leftover GLYPHOXA_DEV_MODE sessions (DevOperatorDiscordID is never allowlisted). An empty allowlist is refused defensively: it would revoke every session, and in allowlist mode the boot preflight guarantees a non-empty list at the only call site (sweepAllowlistSessions).

func (*Store) SaveDefaultVoiceChannel added in v0.6.0

func (s *Store) SaveDefaultVoiceChannel(ctx context.Context, tenantID uuid.UUID, voiceChannelID string) (DeploymentConfig, error)

SaveDefaultVoiceChannel stores the Tenant's Default Voice Channel — the channel a session start falls back to when no explicit pick rides the StartSession request. It requires a linked guild (a default channel is meaningless without one, and the picker that offers channels needs the guild anyway): no bound-guild row yields ErrNotFound, which the RPC maps to a "link a Discord server first" precondition.

func (*Store) SaveDiscordBotToken

func (s *Store) SaveDiscordBotToken(ctx context.Context, tenantID uuid.UUID, ciphertext []byte, last4 string) (DeploymentConfig, error)

SaveDiscordBotToken upserts only the deployment Bot token columns (sealed ciphertext + last4), leaving the Guild / Voice channel IDs untouched. It returns the resulting row. The ciphertext is the caller-sealed secret.

func (*Store) SaveDiscordChannels

func (s *Store) SaveDiscordChannels(ctx context.Context, tenantID uuid.UUID, guildID, voiceChannelID string) (DeploymentConfig, error)

SaveDiscordChannels upserts only the non-secret Guild / Voice channel IDs, leaving the Bot token untouched, and returns the resulting row. A guild_id already bound by a DIFFERENT Tenant trips the first-registrar-wins partial UNIQUE index (23505 on deployment_config_guild_owner) and yields ErrGuildTaken (#483/#504) — the same Tenant re-saving its own guild upserts its own row and never conflicts.

func (*Store) SaveDiscordGuild added in v0.6.0

func (s *Store) SaveDiscordGuild(ctx context.Context, tenantID uuid.UUID, guildID string) (DeploymentConfig, error)

SaveDiscordGuild upserts only the guild_id, leaving the Bot token untouched, and returns the resulting row. The stored Default Voice Channel (voice_channel_id) survives a re-save of the SAME guild but is cleared when the guild changes — a channel snowflake belongs to one guild, so carrying it across a re-bind would point sessions at a channel of the OLD guild. A guild_id already bound by a DIFFERENT Tenant trips the first-registrar-wins partial UNIQUE index (23505 on deployment_config_guild_owner) and yields ErrGuildTaken, exactly like SaveDiscordChannels.

func (*Store) SearchChunksByAgent

func (s *Store) SearchChunksByAgent(ctx context.Context, campaignID, agentID uuid.UUID, query []float32, k int) ([]ChunkMatch, error)

SearchChunksByAgent is NPC-knowledge retrieval (ADR-0011): the k Transcript Chunks in campaignID nearest the query vector by cosine distance whose participated set CONTAINS agentID — "what this NPC could personally know". A multi-agent chunk is returned for every one of its participants (containment, not equality). NULL-embedding rows are excluded (partial HNSW index).

func (*Store) SearchChunksByCampaign

func (s *Store) SearchChunksByCampaign(ctx context.Context, campaignID uuid.UUID, query []float32, k int) ([]ChunkMatch, error)

SearchChunksByCampaign is world-context retrieval (ADR-0011): the k Transcript Chunks in campaignID nearest the query vector by cosine distance, participants ignored — topical Campaign context the NPC "may not personally know". NULL-embedding rows are excluded (partial HNSW index).

func (*Store) SearchNodes

func (s *Store) SearchNodes(ctx context.Context, campaignID uuid.UUID, query string, limit int) ([]KGNode, error)

SearchNodes returns the Campaign's Knowledge Graph Nodes whose name or body match the query, ranked by relevance (ts_rank over the weighted fts column: name weight A outranks body weight B), then newest-first, then id. The search is served by the kg_node_fts_idx GIN index (fts @@ q), not a substring scan. gm_private Nodes are INCLUDED (GM-facing search). An empty BuildTSQuery result yields (nil, nil) — no matches, not an error.

func (*Store) SearchPromotedHighlights added in v0.8.0

func (s *Store) SearchPromotedHighlights(ctx context.Context, tenantID, campaignID uuid.UUID, query string, limit int) ([]Highlight, error)

SearchPromotedHighlights returns the Campaign's PROMOTED Highlights whose excerpt or reason matches the query, ranked by relevance (ts_rank over the weighted fts column: excerpt weight A outranks reason weight B), then newest moment first, then id. Tenant- AND campaign-scoped in the query, like every Highlight read. An empty BuildTSQuery result yields (nil, nil) — no matches, not an error.

func (*Store) SearchPublicNodes

func (s *Store) SearchPublicNodes(ctx context.Context, campaignID uuid.UUID, query string, limit int) ([]KGNode, error)

SearchPublicNodes is SearchNodes with gm_private Nodes EXCLUDED IN THE QUERY — the prompt-facing knowledge search (#296). The exclusion is pushed into the WHERE clause so it applies BEFORE the LIMIT: a post-fetch filter in Go would drop the top-N ranked hits if they were all gm_private and starve a public match ranked N+1. This is the load-bearing ADR-0008 guard for the kg_query Tool — a GM-only Node must never reach an NPC's prompt. Existing GM-facing callers keep SearchNodes (the wiki search shows private Nodes to the GM).

func (*Store) SearchTranscriptLines

func (s *Store) SearchTranscriptLines(ctx context.Context, campaignID uuid.UUID, query string, limit int) ([]TranscriptLine, error)

SearchTranscriptLines returns a Campaign's persisted transcript Lines whose text matches the query, ranked by relevance (#120, ADR-0011 amendment). This is the SINGLE user-facing search path — both the web SearchTranscriptLines RPC and the `/glyphoxa search` slash command call exactly this method (AC4: no divergent search logic). The match is served by the transcript_line_fts_idx GIN index (fts @@ q), not a substring scan, and ranked with ts_rank (a line that mentions the term more often ranks higher), then newest-first, then a stable tiebreak. The query is scoped by campaign_id, so another Campaign's transcript is NEVER returned (AC5). The raw query is sanitized by BuildTSQuery (the same helper the KG search uses), so injected tsquery operators can only ever split words; an empty result from that yields (nil, nil) — no matches, not an error.

func (*Store) SessionLinesForIndexing added in v0.5.0

func (s *Store) SessionLinesForIndexing(ctx context.Context, sessionID uuid.UUID) ([]IndexableLine, error)

SessionLinesForIndexing returns a session's committed Transcript Lines.

Committed only, because that is all transcript_line ever holds: a partial never reaches it (ADR-0012/ADR-0040), so "appearances come from committed lines" needs no filter here — it is a property of the table.

func (*Store) SetActiveCampaign

func (s *Store) SetActiveCampaign(ctx context.Context, discordUserID string, campaignID uuid.UUID) error

SetActiveCampaign records the operator's durable Active Campaign choice (#108, ADR-0009) on the users row keyed by Discord snowflake. It UPSERTS the row so a GM who drives the slash-command surface before ever logging into the web tier still gets a persisted selection; a later OAuth login refreshes the display fields (UpsertUser) and preserves this selection. Idempotent. A campaignID that no longer exists trips the active_campaign_id FK (23503) and yields ErrNotFound — the write can never store a dangling pointer, even if the campaign vanishes between a caller's existence check and this write.

func (*Store) SetBoardNodes added in v0.5.0

func (s *Store) SetBoardNodes(ctx context.Context, campaignID, boardID uuid.UUID, nodeIDs []uuid.UUID) error

SetBoardNodes replaces a board's entries with exactly the supplied list, in order. Replace-in-full for the same reason the aspect editor uses it: add, remove and reorder are one save, and positions stay dense by construction.

func (*Store) SetCampaignShareChannel added in v0.2.1

func (s *Store) SetCampaignShareChannel(ctx context.Context, campaignID uuid.UUID, channelID string) error

SetCampaignShareChannel remembers channelID as the Campaign's highlight-share text channel (last-choice-wins). An unknown campaign is ErrNotFound. Persisting it is best-effort from the RPC's view — a failure never fails the share itself, only the pre-selection memory.

func (*Store) SetChunkEmbedding

func (s *Store) SetChunkEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string) error

SetChunkEmbedding fills one chunk's embedding vector and stamps the model that produced it (#116, ADR-0011). The vector is passed as pgvector's text form and cast server-side (::vector) so storage carries no pgvector-go dependency; the column is vector(768), so a wrong-length vector is rejected by Postgres. Once set, the row leaves the NULL-embedding backlog and becomes returnable by the embedding-filtered retrieval query (embedding IS NOT NULL).

func (*Store) SetHighlightImage added in v0.2.1

func (s *Store) SetHighlightImage(ctx context.Context, id uuid.UUID, imageKey, contentType string, sizeBytes int64) error

SetHighlightImage lands an AI-generated image on a Highlight (#311): the enrichment job (ADR-0049) stores the image behind the blob seam (ADR-0048) and then records its key, MIME type, and size here. It is tenant-free (the handler carries no tenant — the id scopes the row, and image_key derives from the tenant baked into the blob key) and returns ErrNotFound if the row is gone (a Highlight deleted between the job's GetHighlight and this write — the handler compensates by deleting the just-stored blob). Idempotent at the row level: a re-run overwrites the same deterministic key with the same fields.

func (*Store) SetHighlightSound added in v0.10.0

func (s *Store) SetHighlightSound(ctx context.Context, id uuid.UUID, kind, soundKey, contentType string, sizeBytes int64) error

SetHighlightSound lands a generated sound asset on a Highlight (#312): the enrichment job stores the audio behind the blob seam (ADR-0048) and then records its key, MIME type, and size here. Tenant-free like SetHighlightImage (the id scopes the row). The write is CONDITIONAL on sound_kind still equalling the kind the job generated for: the GM can change or clear the choice while a generation is in flight, and the stale job's asset must not land under the newer choice. ErrNotFound covers both misses — row gone and kind superseded — and the handler disambiguates with a re-read (only a GONE row compensates the blob; a superseded kind leaves it for the newer job to overwrite).

func (*Store) SetHighlightSoundKind added in v0.10.0

func (s *Store) SetHighlightSoundKind(ctx context.Context, tenantID, id uuid.UUID, kind string) (Highlight, error)

SetHighlightSoundKind records the GM's "Add sound" choice on a PROMOTED Highlight within the tenant (#312), returning the updated row. A non-empty kind ("sting"/"music" — the RPC validates) stamps sound_requested_at and clears any previously landed sound triad, so the row reads "requested but not landed" until the generation job attaches the new asset; kind "" clears the choice entirely (None). The claim column is deliberately untouched: a live generation keeps serializing blob writes, and the superseded job releases its own claim when its conditional land misses (see SetHighlightSound). A missing/foreign id — or a still-candidate row (sound is opt-in AFTER promotion, #312) — yields ErrNotFound.

func (*Store) SetMapImage added in v0.5.0

func (s *Store) SetMapImage(ctx context.Context, campaignID, id uuid.UUID, blobKey string, widthPx, heightPx int) (CampaignMap, error)

SetMapImage repoints a Map at new image bytes (#538). Pins are untouched by design: coordinates are normalized, so a re-upload at a different resolution keeps every pin exactly where the GM put it — which is the whole reason they are normalized.

func (*Store) SetNodeAgent

func (s *Store) SetNodeAgent(ctx context.Context, campaignID, nodeID uuid.UUID, agentID uuid.NullUUID) (KGNode, error)

SetNodeAgent links or unlinks a Node's Character NPC Agent (the "voiced by" link, ADR-0008 amendment). Both paths are scoped to the owning Campaign (#342): every UPDATE matches the Node's campaign_id against the caller's campaignID, so a Node in another Campaign is invisible and yields ErrNotFound — a cross-campaign link or unlink is refused server-side. A valid agentID links: the single UPDATE also matches the Node's campaign against the Agent's in one statement, so a missing or cross-campaign Agent matches no row and yields ErrNotFound; a non-NPC Node trips the DB CHECK (ErrInvalidEdge); an Agent already linked to another Node trips the UNIQUE index (ErrConflict). An invalid agentID unlinks (agent_id = NULL). A missing Node yields ErrNotFound.

func (*Store) SetNodeEmbedding added in v0.2.1

func (s *Store) SetNodeEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string, updatedAt time.Time) error

SetNodeEmbedding fills one Node's embedding vector and stamps the model that produced it (#300, ADR-0011), mirroring SetChunkEmbedding. The vector is passed as pgvector's text form and cast server-side (::vector) so storage carries no pgvector-go dependency; the column is vector(768), so a wrong-length vector is rejected by Postgres. Once set, the row leaves the NULL-embedding backlog and becomes returnable by SimilarNodes.

Unlike a Transcript Chunk, a Node is MUTABLE: a GM edit or a fact-approval can reset the embedding to NULL and bump updated_at WHILE the worker's (up to 60s) Embed call is in flight. Writing the now-stale vector back would install a v1 vector on a v2 row and it would never re-embed (embedding IS NOT NULL). So the write is guarded on the updated_at the worker LISTED: a row edited since is not matched (0 rows) and stays NULL for the next pass to re-embed with fresh text.

func (*Store) SetNodePortrait added in v0.8.0

func (s *Store) SetNodePortrait(ctx context.Context, campaignID, nodeID uuid.UUID, blobKey string) (KGNode, string, error)

SetNodePortrait repoints a Node at new portrait bytes (” clears it) and stamps updated_at = now(), which is the portrait URL's cache validator — a portrait write always mints a NEW blob key (ReplaceMapImage's ritual), so a stale validator can never serve stale bytes.

It returns the updated Node (Aspects included, GM read) and the PREVIOUS portrait blob key (” when there was none) so the caller can release the superseded bytes through the seam. The write is scoped to (id, campaign_id) (#342); a Node in another Campaign yields ErrNotFound.

The embedding columns are deliberately untouched: a portrait changes no embedded text. The updated_at bump can at worst make an in-flight embedworker write miss its stale-guard and re-embed next pass (#300) — correct, merely a re-run.

func (*Store) SetNodeTags added in v0.5.0

func (s *Store) SetNodeTags(ctx context.Context, campaignID, nodeID uuid.UUID, tags []string) error

SetNodeTags replaces a Node's tags with exactly the supplied set, in one transaction. Replace-in-full matches how the chip editor works — add and remove are one save — and there is no ordering to preserve, so no position column.

Tags are created on use: there is no tag table to maintain and no lifecycle to get wrong, which is the point of "free-form".

func (*Store) SetPartyMarker added in v0.5.0

func (s *Store) SetPartyMarker(ctx context.Context, campaignID, sessionID uuid.UUID, mapID, pinID uuid.NullUUID, x, y *float64) error

SetPartyMarker places (or clears) a Voice Session's marker. A zero mapID clears it entirely; a set mapID with no pin and no coordinates means "somewhere on this map", which is a legitimate answer while the GM is still deciding.

The write is scoped to (id, campaign_id): a session from another Campaign matches nothing and yields ErrNotFound.

The Map and Pin are verified to belong to THIS Campaign, and the Pin to that Map, inside the same statement. voice_sessions has only plain single-column FKs to them, so without these EXISTS guards any existing map/pin uuid would be accepted — a marker pointing into another Tenant's world, whose label the location clause would then read out at the table. The guards are in the SQL, not in the caller, because every future caller inherits them there.

func (*Store) SetTenantPlan added in v0.2.1

func (s *Store) SetTenantPlan(ctx context.Context, tenantID uuid.UUID, slug string) (Subscription, error)

SetTenantPlan subscribes a Tenant to the plan with slug, snapshotting the plan's current slug + monthly price onto the new subscription row. Any active subscription is ended first (its EndedAt set), so the partial unique index keeps at most one active row per tenant. ErrNotFound for an unknown slug or tenant; ErrPlanArchived for an archived one.

func (*Store) SetTenantSpendCaps

func (s *Store) SetTenantSpendCaps(ctx context.Context, tenantID uuid.UUID, caps SpendCaps) error

SetTenantSpendCaps writes a Tenant's soft/hard spend caps, storing NULL for a nil pointer (that cap cleared). It does NOT enforce hard >= soft — that validation is the RPC's (InvalidArgument), leaving storage a faithful persistence layer. ErrNotFound when the tenant row does not exist.

func (*Store) SetUserSuspended added in v0.3.0

func (s *Store) SetUserSuspended(ctx context.Context, discordUserID string, suspended bool) error

SetUserSuspended stamps or clears users.suspended_at by Discord snowflake — the open-mode revocation mechanism (ADR-0055). Suspension is enforced by AuthenticateSession's per-request re-check, so it takes effect immediately without deleting sessions; clearing it restores access with the same tokens. Idempotent per direction; ErrNotFound for an unknown user.

func (*Store) SimilarNodePairs added in v0.5.0

func (s *Store) SimilarNodePairs(ctx context.Context, campaignID uuid.UUID, minSimilarity float64, limit int) ([]KGNodePair, error)

SimilarNodePairs returns the Campaign's closest Node pairs by embedding cosine similarity, closest first (#536, ADR-0011). It is the "probable duplicates" derivation the health panel offers — a HINT the GM acts on, never an auto-merge: ADR-0052 rejected auto-merging near-duplicates because similarity is not a semantic judgment and a wrong merge corrupts canon invisibly. The same reasoning applies here, so this read exists only to point at pairs.

It is an exact pairwise scan rather than an ANN probe: a Campaign is hundreds of Nodes, the read is GM-INITIATED (never on render), and an exact answer beats an approximate one for something a human is about to act on. Rows without an embedding are invisible until the backfill worker reaches them.

minSimilarity is the cosine-similarity floor; limit caps the result.

Cost: the pairwise join cannot use the HNSW index — it is a nested loop, O(n²) distance evaluations. At campaign scale (hundreds of Nodes) that is tens of milliseconds, which is why an exact answer is affordable here; it would not be at thousands, and the GM-initiated framing is what keeps that bounded.

func (*Store) SimilarNodes added in v0.2.1

func (s *Store) SimilarNodes(ctx context.Context, campaignID uuid.UUID, query []float32, k int) ([]KGNode, error)

SimilarNodes returns the k Knowledge Graph Nodes in campaignID nearest the query vector by cosine distance (<=>), nearest first — the ADR-0011 similarity hint the GM review surface shows beside a Knowledge Proposal (#300, ADR-0052). NULL-embedding rows are excluded (the partial HNSW index). Unlike the prompt-facing searches this INCLUDES gm_private Nodes: it is GM-facing review only, and it is deliberately NOT part of PromptKGView — prompt assembly cannot reach it (#450). k <= 0 is a caller bug and errors. The query vector reuses encodeVector + a server-side ::vector cast, so storage carries no pgvector-go dependency.

func (*Store) StartVoiceSessionControl added in v0.4.0

func (s *Store) StartVoiceSessionControl(ctx context.Context, id uuid.UUID) (VoiceSessionControl, error)

StartVoiceSessionControl fences the hosting worker's pending→executing CLAIM before it runs the verb (#503 FIX1): a transient finish-write failure then leaves the row 'executing' (never back to 'pending'), so it can never be re-listed and re-dispatched — say publishes exactly once. Fenced WHERE status='pending': a second claim, a cancelled row, or a swept row matches nothing and yields ErrNotFound (the worker skips it). Stamps started_at for the stale-executing sweep.

func (*Store) SweepExpiredJobs

func (s *Store) SweepExpiredJobs(ctx context.Context, kinds []string) (int, error)

SweepExpiredJobs dead-letters every running job in kinds whose lease has expired AND whose attempts have hit max_attempts — the leftover a crash leaves that ClaimJob's runnable guard (attempts < max_attempts) will never re-claim. It stamps a last_error where none was recorded. Returns how many rows were swept. Called once per runner poll before the claim loop.

func (*Store) SweepOrphanedVoiceSessionControls added in v0.4.0

func (s *Store) SweepOrphanedVoiceSessionControls(ctx context.Context, executingStale time.Duration) (int64, error)

SweepOrphanedVoiceSessionControls retires unfinished (pending/executing) control rows the worker will never complete (#503), run each claim-loop tick. Two arms:

  1. Rows whose intent is already TERMINAL (done/dead/failed) — controls are never dispatched during a wind-down (dispatch is gated on the session being live), so a stopping session's stragglers die here with the ENCODED no_active_session cause (FIX3) so the requester decodes it to ErrNoActiveSession and the GM sees the plain guard.
  2. 'executing' rows of a still-live intent stranded past executingStale (a finish-write blip with the requester already gone) — the bounded recovery that keeps such a row from sitting forever (FIX1). Its cause is a plain stall message (uncoded, surfaced verbatim to any late poller).

Returns how many rows were failed.

func (*Store) SyncPlans added in v0.2.1

func (s *Store) SyncPlans(ctx context.Context, specs []PlanSpec, archiveMissing bool) (PlanSyncResult, error)

SyncPlans upserts the catalog specs by slug (reviving a previously archived slug) and, when archiveMissing is set, archives every plan whose slug is absent from specs. Runs in one transaction: a partially applied catalog is never visible. Plans are never deleted — subscriptions reference them.

func (*Store) TenantForUser

func (s *Store) TenantForUser(ctx context.Context, userID uuid.UUID) (uuid.UUID, error)

TenantForUser returns the id of the tenant bound to the operator, or ErrNotFound when none is bound. The X-Tenant-Id interceptor uses it as the thin single-operator pass-through (ADR-0039).

func (*Store) TenantHasPlatformKeySource added in v0.3.0

func (s *Store) TenantHasPlatformKeySource(ctx context.Context, tenantID uuid.UUID) (bool, error)

TenantHasPlatformKeySource reports whether an ACTIVE subscription on a key_source='platform' Plan backs the tenant — the ADR-0054 entitlement-seam read behind llmbuild.SubscriptionKeyGate (ADR-0055 gate (a)). A BYOK plan, an ended subscription, no subscription, or an unknown tenant are all plainly false, never an error: absence of entitlement is the expected common case.

func (*Store) TenantIncludedUsageUSD added in v0.3.0

func (s *Store) TenantIncludedUsageUSD(ctx context.Context, tenantID uuid.UUID) (*float64, error)

TenantIncludedUsageUSD returns the monthly usage allowance of the Tenant's ACTIVE subscription's plan, joined LIVE from the plan row (catalog edits to the allowance apply immediately — subscriptions snapshot only slug + price). nil means no gate applies: no active subscription, or a plan with no configured allowance (NULL — every BYOK plan, by catalog validation). This is one of the two reads behind the ADR-0055 monthly allowance gate (b); the gating decision itself lives outside storage.

func (*Store) TenantMonthUsageUSD added in v0.3.0

func (s *Store) TenantMonthUsageUSD(ctx context.Context, tenantID uuid.UUID, from, to time.Time) (float64, error)

TenantMonthUsageUSD sums the Usage Ledger's estimated USD for the tenant over days in [from, to) — the BillingReport window convention. A tenant with no rows sums to zero. The ledger stays attribution-only (ADR-0054): this is a plain read the allowance gate consumes, not a gate.

func (*Store) TryClaimHighlightEnrich added in v0.2.1

func (s *Store) TryClaimHighlightEnrich(ctx context.Context, id uuid.UUID, ttl time.Duration) (bool, error)

TryClaimHighlightEnrich atomically claims the image enrichment of a Highlight (#406): a conditional UPDATE that stamps image_enrich_claimed_at iff the row is still imageless AND no claim newer than ttl is held. It reports whether THIS caller won (RowsAffected == 1). A false-no-error means a live worker holds the claim or the row was enriched meanwhile. The lease (ttl) makes a crashed claimant's claim reclaimable, so a Highlight is never stranded imageless. The column is never scanned onto the wire, so the marker cannot leak into an RPC response. Tenant-free (the id scopes the row, like SetHighlightImage).

func (*Store) TryClaimHighlightSoundEnrich added in v0.10.0

func (s *Store) TryClaimHighlightSoundEnrich(ctx context.Context, id uuid.UUID, ttl time.Duration) (bool, error)

TryClaimHighlightSoundEnrich atomically claims the sound generation of a Highlight (#312, the #406 image-claim pattern): a conditional UPDATE that stamps sound_enrich_claimed_at iff a sound is still requested-but-unlanded AND no claim newer than ttl is held. It reports whether THIS caller won (RowsAffected == 1). The single-clock lease (#421) applies: stamp and cutoff are both DB now(), the app contributes only the TTL magnitude. Tenant-free.

func (*Store) UnarchiveCampaign

func (s *Store) UnarchiveCampaign(ctx context.Context, tenantID, id uuid.UUID) (Campaign, error)

UnarchiveCampaign clears a campaign's archived_at, returning it to the active set, and returns the updated row (#269). A missing id yields ErrNotFound. Un-archiving does not restore any operator's cleared durable selection (that pointer was nulled on archive) — the campaign simply becomes selectable again.

It is TENANT-SCOPED (#473): the UPDATE matches (id, tenant_id), so a foreign-tenant id is invisible and yields ErrNotFound.

func (*Store) UnpinnedNodes added in v0.5.0

func (s *Store) UnpinnedNodes(ctx context.Context, campaignID, mapID uuid.UUID) ([]KGNode, error)

UnpinnedNodes returns the Campaign's Nodes that are NOT pinned on a given Map, restricted to the types worth placing (#538): Locations, NPCs and Items are things that occupy space; Factions, Plot threads and Notes are not.

It backs the Maps tab's "unpinned entries" tray, which is what makes placing the world a drag rather than a form.

func (*Store) UpdateAgent

func (s *Store) UpdateAgent(ctx context.Context, a AgentUpdate) (Agent, error)

UpdateAgent updates an Agent's editor fields and returns the updated row. It never changes agent_role, and it force-keeps a Butler's address_only true (the Butler always waits to be named, ADR-0024) — so editing the Butler can neither demote it nor turn off Address-Only. A missing id yields ErrNotFound.

func (*Store) UpdateBoard added in v0.5.0

func (s *Store) UpdateBoard(ctx context.Context, campaignID, id uuid.UUID, name string, nodeIDs []uuid.UUID) error

UpdateBoard renames a board AND replaces its entries in ONE transaction.

The two were separate calls, so a failed entry write (a stale node id, an entry deleted by a concurrent edit) left the board renamed with its old contents and returned an opaque error — a half-applied save the GM has no way to reason about. A board edit is one edit.

func (*Store) UpdateCampaign

func (s *Store) UpdateCampaign(ctx context.Context, c CampaignUpdate) (Campaign, error)

UpdateCampaign writes a campaign's name/system/language (and tape_armed when set) and bumps updated_at, returning the updated row. It is TENANT-SCOPED (#473): the UPDATE matches (id, tenant_id), so a foreign-tenant id is invisible and yields ErrNotFound (the RPC layer maps it to Connect CodeNotFound) — a cross-tenant write can never land.

func (*Store) UpdateCharacter

func (s *Store) UpdateCharacter(ctx context.Context, u CharacterUpdate) (Character, error)

UpdateCharacter saves a Character's editor fields (name/aliases/discord_user_id) and returns the updated row, stamping updated_at = now(). The write is scoped to (id, campaign_id) (#342), so a Character in another Campaign matches no row and yields ErrNotFound — a cross-campaign mutation is refused server-side without a separate ownership SELECT. Rebinding discord_user_id is a normal field write; a collision with another Character's (campaign, discord_user_id) yields ErrConflict. A missing id yields ErrNotFound.

func (*Store) UpdateEdgeDetails added in v0.5.0

func (s *Store) UpdateEdgeDetails(ctx context.Context, campaignID, id uuid.UUID, note string, disposition int) (KGEdge, error)

UpdateEdgeDetails saves an Edge's note and disposition, scoped to its Campaign (#342, #546). The relation TYPE is not touched: retyping an edge is deleting one and creating another, since the type carries validity rules.

func (*Store) UpdateMap added in v0.5.0

func (s *Store) UpdateMap(ctx context.Context, u CampaignMapUpdate) (CampaignMap, error)

UpdateMap saves a Map's editor fields, scoped to (id, campaign_id) (#342).

A Map may not become its own parent; deeper cycles are refused by Store.MapAncestors at read time rather than by a recursive CHECK, since the hierarchy is shallow and a GM re-parenting into a loop is a mistake to report, not a constraint violation to crash on.

func (*Store) UpdateNode

func (s *Store) UpdateNode(ctx context.Context, u KGNodeUpdate) (KGNode, error)

UpdateNode saves a Knowledge Graph Node's editor fields (name/body/gm_private) and returns the updated row, stamping updated_at = now(). node_type is never touched (immutable, ADR-0008). The write is scoped to (id, campaign_id) (#342), so a Node in another Campaign matches no row and yields ErrNotFound — a cross-campaign mutation is refused server-side. A missing id yields ErrNotFound.

func (*Store) UpdateNodeWithAspects added in v0.5.0

func (s *Store) UpdateNodeWithAspects(ctx context.Context, u KGNodeUpdate, w KGNodeAspectWrite) (KGNode, error)

UpdateNodeWithAspects saves a Node's editor fields and its Aspects in ONE transaction (#542), so a save is never half-applied — the editor's name change and its aspect edits are one act to the GM and must be one act to the database. A missing Node yields ErrNotFound with nothing written.

func (*Store) UpdatePin added in v0.5.0

func (s *Store) UpdatePin(ctx context.Context, u MapPinUpdate) (MapPin, error)

UpdatePin saves a Pin's position and presentation, scoped to its Campaign (#342). Out-of-range coordinates are refused by the DB CHECK.

func (*Store) UpsertProviderConfigs

func (s *Store) UpsertProviderConfigs(ctx context.Context, configs []NewProviderConfig) ([]ProviderConfig, error)

UpsertProviderConfigs inserts-or-replaces a batch of Provider Configs in one transaction, keyed by (tenant_id, component, provider): a matching row has its model + sealed credential + last4 refreshed (the operator replacing a key, or the first real key overwriting the seed's "env" placeholder), otherwise a new row is inserted. The batch is atomic so a multi-Component provider (ElevenLabs → stt + tts share one key, ADR-0004) lands all-or-nothing. Returns the resulting rows in input order. Requires the UNIQUE (tenant_id, component, provider) key from migration 00005.

func (*Store) UpsertTapeConsent

func (s *Store) UpsertTapeConsent(ctx context.Context, campaignID uuid.UUID, discordUserID string) error

UpsertTapeConsent records that a Speaker has consented to the rollover tape for a Campaign (#306, ADR-0051). It is idempotent: consenting twice keeps the original created_at. The (campaign_id, discord_user_id) primary key makes the row's presence the single source of truth for "this Speaker consented".

func (*Store) UpsertToolGrant

func (s *Store) UpsertToolGrant(ctx context.Context, g NewToolGrant) error

UpsertToolGrant grants a Tool to an Agent, or edits an existing grant's scope Config in place — the #117 mutation path. It INSERTs the (agent_id, tool_name) row when absent and UPDATEs its Config when present, keyed off the UNIQUE(agent_id, tool_name) index, so "grant on" and "edit scope" are one call and a repeated grant never trips the unique constraint. An empty Config stores SQL NULL (no narrowing — dice's shape), so re-upserting nil clears a prior scope. The Agent's next Voice Session hydrates the resulting row (#113).

func (*Store) UpsertTranscriptLine

func (s *Store) UpsertTranscriptLine(ctx context.Context, l TranscriptLine) error

UpsertTranscriptLine writes (or updates in place) one transcript Line. An Agent reply coalesces across its sentences under one line_id, so a re-write of the same (voice_session_id, line_id) updates the text/ts rather than inserting a new row — keeping COUNT(*) == distinct lines. seq is deliberately NOT updated on conflict (#149): it is the replay ordering key, fixed at insert time, so ListTranscriptLines (ORDER BY seq) matches the live-view order even when an interleaved line landed between a reply's sentences.

func (*Store) UpsertUser

func (s *Store) UpsertUser(ctx context.Context, p UpsertUserParams) (User, error)

UpsertUser inserts a user keyed by discord_user_id, or refreshes the display name/avatar of the existing row (Discord is the source of truth for those on every login). It returns the resulting user. The role is preserved on conflict so an operator promotion is never clobbered by a login.

func (*Store) VoiceSessionInTenant added in v0.2.1

func (s *Store) VoiceSessionInTenant(ctx context.Context, tenantID, sessionID uuid.UUID) (bool, error)

VoiceSessionInTenant reports whether the Voice Session belongs to the Tenant (session → campaign → tenant). It backs the transcript snapshot/SSE mounts' tenant-scoped 404 posture (#439, via transcript.TenantScope): false covers both a foreign-tenant session and one that does not exist at all, so the caller's 404 never reveals which.

type Subscription added in v0.2.1

type Subscription struct {
	ID              uuid.UUID
	TenantID        uuid.UUID
	PlanID          uuid.UUID
	PlanSlug        string
	MonthlyPriceUSD float64
	StartedAt       time.Time
	EndedAt         *time.Time
}

Subscription is a Tenant's binding to a Plan. PlanSlug and MonthlyPriceUSD are snapshots taken at subscribe time (revenue history survives catalog edits); EndedAt is nil while the subscription is active.

type TaggedNode added in v0.5.0

type TaggedNode struct {
	NodeID uuid.UUID
	Tag    string
}

TaggedNode is one (node, tag) pair — the campaign-wide read the list and graph filters index client-side, so tagging a hundred entries is still one round trip.

type Tenant

type Tenant struct {
	ID   uuid.UUID
	Name string
	// SpendCapSoftUSD / SpendCapHardUSD are the two independently opt-in per-Tenant
	// spend caps (#130, ADR-0046): nil = that cap is off. They gate a Voice Session's
	// estimated spend (an approximate figure, never a billed amount).
	SpendCapSoftUSD *float64
	SpendCapHardUSD *float64
	CreatedAt       time.Time
	UpdatedAt       time.Time
}

Tenant is the top-level isolation boundary.

type TenantBillingLine added in v0.2.1

type TenantBillingLine struct {
	TenantID        uuid.UUID
	TenantName      string
	PlanSlug        string  // empty: no subscription overlapped the window
	MonthlyPriceUSD float64 // 0 when PlanSlug is empty
	EstimatedUSD    float64
	LLMInputTokens  int64
	LLMOutputTokens int64
	TTSCharacters   int64
	STTAudioSeconds float64
}

TenantBillingLine is one tenant's row in the billing report window: the active or overlapping subscription snapshot(s) plus the ledger's summed estimated cost. A tenant that switched plans mid-window appears once per subscription (revenue is per subscription row); usage is attached to the FIRST line only so summing the report never double-counts cost.

type TenantOperatorBinding added in v0.4.0

type TenantOperatorBinding struct {
	TenantID      uuid.UUID
	DiscordUserID string
}

TenantOperatorBinding pairs a Tenant with its operator's Discord snowflake — one row of the per-Tenant GM-identity source (#490). It lets GMIdentity scope GM standing to the OWNING Tenant (IsGMInTenant) instead of the deployment-wide union ListTenantOperatorDiscordIDs feeds, closing ADR-0055's deployment-scope caveat: a Tenant A operator is GM in Tenant A only, never in Tenant B's Guild.

type TenantPlanRow added in v0.2.1

type TenantPlanRow struct {
	ID        uuid.UUID
	Name      string
	CreatedAt time.Time
	PlanSlug  string // empty: no active subscription
}

TenantPlanRow is one line of ListTenantsWithPlan.

type ToolGrant

type ToolGrant struct {
	ID       uuid.UUID
	AgentID  uuid.UUID
	ToolName string
	// Surface is which Agent surface the grant arms (#592, ADR-0062): chat
	// Tool Grants are separate rows beside the voice grants, so granting the
	// Butler a chat tool never widens what it may call in voice (ADR-0029).
	Surface   GrantSurface
	Config    json.RawMessage
	CreatedAt time.Time
	UpdatedAt time.Time
}

ToolGrant is an Agent's persisted permission to invoke one named Tool (ADR-0029) — the DB shape of the in-memory Grant the live loop hydrates into a GrantSet (#113). Config is the optional per-grant scope/config (jsonb): nil when the grant carries no narrowing (dice), a scope blob for a Tool granted differently per Agent. It reaches the Tool handler at execution time and is enforced there, never by the LLM.

type TranscriptChunk

type TranscriptChunk struct {
	ID                    uuid.UUID
	CampaignID            uuid.UUID
	VoiceSessionID        uuid.UUID   // column nullable; the writer always sets it
	Content               string      // the chunk's utterances joined "\n"
	SpeakerDiscordUserIDs []string    // distinct Speaker Lane snowflakes in the chunk (populated since #278/ADR-0050); empty only when all utterances were unattributed
	ParticipatedAgentIDs  []uuid.UUID // Agents that spoke in the chunk (NPC-knowledge filter)
	EmbeddingModel        string      // ” until the backfill worker embeds the row (#116)
	StartedAt             time.Time
	CreatedAt             time.Time
}

TranscriptChunk is one persisted Transcript Chunk. embedding is always NULL at insert (the async embedding pipeline, ADR-0011); EmbeddingModel is empty until the backfill worker (#116) embeds the row and stamps which model produced the vector — the provenance a model-switch re-embed pass keys off (ADR-0011).

type TranscriptLine

type TranscriptLine struct {
	VoiceSessionID uuid.UUID
	CampaignID     uuid.UUID
	LineID         string
	Seq            int64
	Who            string
	Tag            string
	Kind           string
	TS             time.Time
	Text           string
	// SpeakerDiscordUserID is the Discord snowflake of the human who spoke this Line
	// (#278, ADR-0050), or "" for an unattributed utterance / an Agent reply. It
	// round-trips "" ↔ NULL: NULLIF on write, COALESCE on scan.
	SpeakerDiscordUserID string
}

TranscriptLine is one persisted transcript Line. LineID is the relay's stable Line.ID; Seq is the relay's monotonic per-session ordering key (Frame.Seq).

type UpsertUserParams

type UpsertUserParams struct {
	DiscordUserID string
	Name          string
	Avatar        string
}

UpsertUserParams is the input to UpsertUser: the Discord identity to insert or refresh. Role is not an input — a new user defaults to 'operator' (the DB default) and an existing user's role is left untouched on refresh.

type UsageRow added in v0.2.1

type UsageRow struct {
	TenantID        uuid.UUID
	Day             time.Time
	Component       Component
	Provider        string
	Model           string
	LLMInputTokens  int64
	LLMOutputTokens int64
	TTSCharacters   int64
	STTAudioSeconds float64
	EstimatedUSD    float64
}

UsageRow is one daily-bucketed usage accumulation the ledger sink flushes (ADR-0054). Day is a calendar date (UTC); only its date part is stored. EstimatedUSD is priced from the static map at capture time — an ESTIMATE, never billing truth (ADR-0046 posture).

type User

type User struct {
	ID            uuid.UUID
	DiscordUserID string
	Name          string
	// Avatar is an absolute image URL (or empty).
	Avatar    string
	Role      string
	CreatedAt time.Time
	UpdatedAt time.Time
	// SuspendedAt, when non-nil, marks the user locked out under the
	// open-Admission-Mode revocation mechanism (ADR-0055).
	SuspendedAt *time.Time
	// AUPAcceptedAt records when the user last acknowledged the
	// Nutzungsbedingungen + Datenschutzerklärung at an open-mode OAuth start
	// (#518). nil for allowlist-mode logins (not gated) and pre-column
	// accounts. Appended LAST — userColumns/scanUser are column-order-coupled.
	AUPAcceptedAt *time.Time
}

User is a human operator authenticated via Discord OAuth (ADR-0016). The Discord snowflake is the stable identity key; Name/Avatar are display-only and refreshed from Discord on each login.

type VoiceSession

type VoiceSession struct {
	ID         uuid.UUID
	CampaignID uuid.UUID
	StartedAt  time.Time
	EndedAt    *time.Time
	Status     VoiceSessionStatus
	LineCount  int
	EndReason  *string
}

VoiceSession is one run of the live voice loop — the Bot's presence in one Discord voice channel, bound to a Campaign (CONTEXT.md "Voice Session", #72). EndedAt is nil while running; LineCount records transcript lines produced (0 for this stage — the live feed is #73). EndReason is nil for a clean end, and set when the boot reconciliation closed an orphaned row (#143) or a fatal gateway rejection ended the session as 'failed' (#123, the readable cause).

type VoiceSessionControl added in v0.4.0

type VoiceSessionControl struct {
	ID       uuid.UUID
	IntentID uuid.UUID
	TenantID uuid.UUID
	Kind     VoiceSessionControlKind
	AgentID  string
	SayText  string
	Muted    bool
	// DirectTurns is the 'direct' verb's committed-turn bound (ADR-0059): how
	// many of the Agent's committed turns the directive rides; 0 = sticky.
	DirectTurns int
	Status      VoiceSessionControlStatus
	ResultIDs   []string
	LastError   string
	CreatedAt   time.Time
	StartedAt   *time.Time
	EndedAt     *time.Time
}

VoiceSessionControl is one row of the requested-control queue (#503).

type VoiceSessionControlKind added in v0.4.0

type VoiceSessionControlKind string

VoiceSessionControlKind names one control verb.

const (
	// VoiceControlMuteAgent mutes/unmutes one voiced Agent (Manager.SetAgentMute).
	VoiceControlMuteAgent VoiceSessionControlKind = "mute_agent"
	// VoiceControlMuteAll mutes/unmutes every voiced Agent (Manager.SetAllMute).
	VoiceControlMuteAll VoiceSessionControlKind = "mute_all"
	// VoiceControlSay makes one voiced Agent speak SayText (Manager.SayAs).
	VoiceControlSay VoiceSessionControlKind = "say"
	// VoiceControlButlerSay makes the Butler speak SayText (Manager.SpeakAsButler)
	// — the voiced-recap relay.
	VoiceControlButlerSay VoiceSessionControlKind = "butler_say"
	// VoiceControlDirect sets/clears one Agent's GM directive (Manager.DirectAs,
	// ADR-0059): SayText carries the directive text (” clears), DirectTurns the
	// committed-turn bound (0 = sticky until cleared/replaced/session end).
	VoiceControlDirect VoiceSessionControlKind = "direct"
)

type VoiceSessionControlStatus added in v0.4.0

type VoiceSessionControlStatus string

VoiceSessionControlStatus is a control row's lifecycle state.

const (
	// VoiceControlPending: written by the requester, not yet claimed.
	VoiceControlPending VoiceSessionControlStatus = "pending"
	// VoiceControlExecuting: the hosting worker fenced a pending→executing claim
	// and is running the verb. A transient finish-write failure leaves the row
	// here (never back to pending), so it can never re-dispatch (#503 FIX1 — say
	// is not idempotent); the requester treats it as non-terminal and keeps
	// polling, and the sweep retires a row stranded here.
	VoiceControlExecuting VoiceSessionControlStatus = "executing"
	// VoiceControlDone: the hosting worker executed it successfully.
	VoiceControlDone VoiceSessionControlStatus = "done"
	// VoiceControlFailed: execution failed (LastError carries the encoded cause),
	// the requester timed out, or the session ended with the row unfinished.
	VoiceControlFailed VoiceSessionControlStatus = "failed"
)

type VoiceSessionIntent added in v0.4.0

type VoiceSessionIntent struct {
	ID         uuid.UUID
	TenantID   uuid.UUID
	CampaignID uuid.UUID
	// VoiceChannelID is the voice channel the start explicitly picked (” = use
	// the guild's Default Voice Channel); the claiming worker passes it through
	// to Manager.Start.
	VoiceChannelID string
	Status         VoiceSessionIntentStatus
	InstanceID     string
	VoiceSessionID uuid.NullUUID
	StopRequested  bool
	LastError      string
	CreatedAt      time.Time
	ClaimedAt      *time.Time
	HeartbeatAt    *time.Time
	EndedAt        *time.Time
}

VoiceSessionIntent is one row of the voice-session claim plane (#491): a tenant-keyed intent a web Start writes and a -mode voice worker claims, runs, and heartbeats. VoiceSessionID is set once the worker goes live; ClaimedAt / HeartbeatAt / EndedAt track the claim lifecycle; LastError carries a fault's readable cause. Mirrors the storage.Job shape (ADR-0049).

type VoiceSessionIntentStatus added in v0.4.0

type VoiceSessionIntentStatus string

VoiceSessionIntentStatus is a Voice Session Intent's lifecycle state (#491, ADR-0057 (b)): the claim-plane row a web-tier Start writes and a -mode voice worker claims. 'pending' → 'claimed' (a worker took it) → 'live' (its loop is up) → 'done' (clean end) / 'failed' (loop fault) / 'dead' (the worker's heartbeat went stale — no takeover, ADR-0006). The three non-terminal states share the one-live-per-tenant partial UNIQUE index.

const (
	// VoiceIntentPending: written by Start, not yet claimed by any worker.
	VoiceIntentPending VoiceSessionIntentStatus = "pending"
	// VoiceIntentClaimed: a worker won the FOR UPDATE SKIP LOCKED claim and stamped
	// its instance_id; its loop is starting but not yet live.
	VoiceIntentClaimed VoiceSessionIntentStatus = "claimed"
	// VoiceIntentLive: the worker's voice loop is up and the voice_sessions row is
	// bound; the worker heartbeats while in this state.
	VoiceIntentLive VoiceSessionIntentStatus = "live"
	// VoiceIntentDone: terminal clean end (Stop honored, or the loop self-exited).
	VoiceIntentDone VoiceSessionIntentStatus = "done"
	// VoiceIntentDead: terminal — the owning worker's heartbeat went stale, so the
	// reaper marked it dead. No mid-session takeover (ADR-0006/0057 (e)): the Tenant
	// restarts; the session is NEVER handed to another Voice Instance.
	VoiceIntentDead VoiceSessionIntentStatus = "dead"
	// VoiceIntentFailed: terminal — the claiming worker could not run the session
	// (e.g. the Manager refused it), last_error carries the readable cause.
	VoiceIntentFailed VoiceSessionIntentStatus = "failed"
)

type VoiceSessionStatus

type VoiceSessionStatus string

VoiceSessionStatus is a Voice Session's lifecycle state (#72). A session is 'running' from Start until Stop (or loop exit), then 'ended' — or 'failed' when a fatal, non-retryable gateway rejection ended it (#123).

const (
	VoiceSessionRunning VoiceSessionStatus = "running"
	VoiceSessionEnded   VoiceSessionStatus = "ended"
	// VoiceSessionFailed is the terminal state of a session whose Discord gateway
	// connection failed FATALLY — a non-retryable rejection (invalid Bot token,
	// disallowed intents, gateway reject) the reconnect loop stopped on rather than
	// backing off forever (#123). The row's end_reason carries the readable cause.
	// Like 'ended' it is terminal (never revived), but records that the session
	// never served — distinct from a clean stop.
	VoiceSessionFailed VoiceSessionStatus = "failed"
)

Directories

Path Synopsis
Package crypto encrypts BYOK Provider Config credentials at rest (ADR-0004): AES-256-GCM with a single app-level secret supplied via env var.
Package crypto encrypts BYOK Provider Config credentials at rest (ADR-0004): AES-256-GCM with a single app-level secret supplied via env var.
Package migrations holds the embedded SQL migration files for the Glyphoxa schema.
Package migrations holds the embedded SQL migration files for the Glyphoxa schema.

Jump to

Keyboard shortcuts

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