Files
coder/coderd/database/queries/siteconfig.sql
T
Mathias FredrikssonandMichael Suchacz d3f08b1983 feat: audit chat system instructions changes (#27668)
Adds an audit record for administrative events on the deployment-wide
chat instruction settings (system prompt, the include-default toggle,
and the plan-mode instructions), per CODAGT-719 and operator decision
D5. Each endpoint records under a stable identity: resource type
`chat_instruction_settings`, a fixed resource ID and a human-readable
target ("System prompt", "Plan mode instructions"), so two changes to
one setting share an ID and history-by-setting works. A real change
exports a Write entry with the old-to-new text visible; a
value-identical PUT still upserts and still returns 204 but records
nothing.

Attempts are recorded, not only transitions. Identity is assigned before
the authorization check, so a denied PUT exports a 403 row with an empty
diff (no request content reaches it), a validation failure exports a 400
row, and a write failure exports a 500 row, each with an empty diff; an
operator can tell "nothing changed" from "something changed and capture
degraded" by the status code.

The write path stays authoritative. The advisory lock and, on plan-mode,
the transaction exist only to serve change-detection; if any of that
machinery fails (lock, begin, commit, rollback), the handler runs main's
idempotent write path directly and derives the response from it, so a
member-visible failure of audit-only infrastructure can never replace
main's successful response. Accepted consequence: when the lock cannot
be taken, two concurrent identical writes can produce two rows instead
of one. That is audit degradation, which is allowed; changing a member's
response is not. Write failures keep the exact response the endpoint
produced before this wiring (transaction error for the system prompt,
which was always transactional; the raw write error for plan mode, which
was not), and the full transaction error is logged so rollback failures
cannot vanish.

<details>
<summary>CODAGT-66 plan entry: S1 (verbatim)</summary>

**S1 `feat: audit chat system instructions changes`** (CODAGT-719; base:
main)

- Struct: `database.ChatSystemPromptSettings{ID uuid.UUID; SystemPrompt
string; IncludeDefaultSystemPrompt bool; PlanModeInstructions string}`
in `coderd/database/types.go` (ticket-sketched shape; one struct, both
endpoints).
- Registration: union entry (diff.go), table.go entry (`id`
ActionIgnore, other three ActionTrack), `AuditActionMap` Write-only;
four request.go cases (`ResourceTarget` "", `ResourceID` from struct,
`ResourceType` new enum value `chat_system_prompt_settings`,
`ResourceRequiresOrgID` false with the "Artificial ID / deployment
singleton" comment convention).
- Migration: `ALTER TYPE resource_type ADD VALUE IF NOT EXISTS
'chat_system_prompt_settings';` comment-only no-op down (000558 shape);
number picked at push per the numbering constraint.
- codersdk: constant + prose `FriendlyString` ("chat system prompt
settings"); `TestAuditDBEnumsCovered` forces both. `coderd/audit.go`
presentation switches: rely on safe defaults (no link, generic
description); no FE changes (filter label falls back to capitalized
value; acceptable per precedent).
- Wiring `putChatSystemPrompt` and `putChatPlanModeInstructions`:
InitRequest with Action Write; artificial `ID: uuid.New()` on `New` only
when a change is detected; no-op suppression by leaving both aReq sides
unset (nil resource IDs skip the log, request.go skip rule); the write
path itself stays byte-identical (upserts still run unconditionally).
- `putChatSystemPrompt` (writes two keys conditionally in one existing
tx): inside that tx, read the pair via `GetChatSystemPromptConfig` for
`Old`, perform the conditional writes exactly as today, then RE-READ the
pair for `New`. The re-read is load-bearing:
`include_default_system_prompt` is computed from the toggle row AND the
prompt, so a prompt-only write can flip the effective value without the
request carrying the pointer. `PlanModeInstructions` stays zero on both
sides.
- `putChatPlanModeInstructions` (no tx exists today): wrap its
read-upsert in `InTx` (behavior-preserving: same single write);
`Old`/`New` populate only `PlanModeInstructions`; the two system-prompt
fields stay zero on both sides; no cross-key reads.
- Change detection compares the populated payload fields only (never the
artificial ID).
- Tests: handler-level coderdtest with `audit.NewMock()` asserting Write
entry on change and NO entry on a value-identical PUT, for both
endpoints (this also exercises `ResourceRequiresOrgID` end to end); the
fallback-flip case (no explicit include-default row, nonempty prompt set
to empty, effective boolean flips: entry emitted with the boolean diff);
diff assertions (old->new prompt text tracked, not secret) in
`enterprise/audit/diff_internal_test.go`; `TestAuditableResources`
passes by construction.
- Bookkeeping at PR open: correct CODAGT-719's no-op premise ("matches
the existing 204-on-unchanged behavior" does not exist on main;
suppression is new, write path unchanged).
- Review focus: Old capture and the New re-read inside the tx (three of
four existing singletons never set Old; do not copy them; and the
computed include-default value makes a naive New construction wrong);
the skip-on-no-op mechanism; prompt text deliberately visible in diffs.

</details>

Note: the plan excerpt above predates operator decision D5 (2026-07-30),
which this PR implements: the resource type is
`chat_instruction_settings` (not `chat_system_prompt_settings`), each
setting carries a stable ID and a display-name target (not a per-write
artificial ID and an empty target), no-op suppression runs through
`InitRequestWithCancel` (not the nil-ID skip), and attempts (denied,
failed, capture-degraded) record rows with real statuses and empty
diffs. Ticket bookkeeping for CODAGT-719 was corrected on Linear at
kickoff: the ticket's "matches the existing 204-on-unchanged behavior"
premise does not exist on main; suppression is new, and the write path
is unchanged.

> 🤖 This PR was created with the help of Coder Agents, and _will be_
reviewed by a human. 🏂🏻

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
2026-08-18 20:03:06 +02:00

413 lines
16 KiB
SQL

-- name: UpsertDefaultProxy :exec
-- The default proxy is implied and not actually stored in the database.
-- So we need to store it's configuration here for display purposes.
-- The functional values are immutable and controlled implicitly.
INSERT INTO site_configs (key, value)
VALUES
('default_proxy_display_name', @display_name :: text),
('default_proxy_icon_url', @icon_url :: text)
ON CONFLICT
(key)
DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key
;
-- name: GetDefaultProxyConfig :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'default_proxy_display_name'), 'Default') :: text AS display_name,
COALESCE((SELECT value FROM site_configs WHERE key = 'default_proxy_icon_url'), '/emojis/1f3e1.png') :: text AS icon_url
;
-- name: InsertDeploymentID :exec
INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1);
-- name: GetDeploymentID :one
SELECT value FROM site_configs WHERE key = 'deployment_id';
-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1);
-- name: GetDERPMeshKey :one
SELECT value FROM site_configs WHERE key = 'derp_mesh_key';
-- name: UpsertLastUpdateCheck :exec
INSERT INTO site_configs (key, value) VALUES ('last_update_check', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update_check';
-- name: GetLastUpdateCheck :one
SELECT value FROM site_configs WHERE key = 'last_update_check';
-- name: UpsertAnnouncementBanners :exec
INSERT INTO site_configs (key, value) VALUES ('announcement_banners', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'announcement_banners';
-- name: GetAnnouncementBanners :one
SELECT value FROM site_configs WHERE key = 'announcement_banners';
-- name: UpsertLogoURL :exec
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';
-- name: GetLogoURL :one
SELECT value FROM site_configs WHERE key = 'logo_url';
-- name: UpsertApplicationName :exec
INSERT INTO site_configs (key, value) VALUES ('application_name', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application_name';
-- name: GetApplicationName :one
SELECT value FROM site_configs WHERE key = 'application_name';
-- name: GetHealthSettings :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'health_settings'), '{}') :: text AS health_settings
;
-- name: UpsertHealthSettings :exec
INSERT INTO site_configs (key, value) VALUES ('health_settings', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'health_settings';
-- name: GetNotificationsSettings :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings
;
-- name: UpsertNotificationsSettings :exec
INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings';
-- name: GetPrebuildsSettings :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'prebuilds_settings'), '{}') :: text AS prebuilds_settings
;
-- name: UpsertPrebuildsSettings :exec
INSERT INTO site_configs (key, value) VALUES ('prebuilds_settings', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'prebuilds_settings';
-- name: GetRuntimeConfig :one
SELECT value FROM site_configs WHERE site_configs.key = $1;
-- name: UpsertRuntimeConfig :exec
INSERT INTO site_configs (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1;
-- name: DeleteRuntimeConfig :exec
DELETE FROM site_configs
WHERE site_configs.key = $1;
-- name: GetOAuth2GithubDefaultEligible :one
SELECT
CASE
WHEN value = 'true' THEN TRUE
ELSE FALSE
END
FROM site_configs
WHERE key = 'oauth2_github_default_eligible';
-- name: UpsertOAuth2GithubDefaultEligible :exec
INSERT INTO site_configs (key, value)
VALUES (
'oauth2_github_default_eligible',
CASE
WHEN sqlc.arg(eligible)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(eligible)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'oauth2_github_default_eligible';
-- name: GetOAuth2DCREnabled :one
SELECT COALESCE(
(SELECT value = 'true' FROM site_configs WHERE key = 'oauth2_dcr_enabled'),
false
)::bool;
-- name: UpsertOAuth2DCREnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
'oauth2_dcr_enabled',
CASE
WHEN sqlc.arg(enabled)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(enabled)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'oauth2_dcr_enabled';
-- name: UpsertWebpushVAPIDKeys :exec
INSERT INTO site_configs (key, value)
VALUES
('webpush_vapid_public_key', @vapid_public_key :: text),
('webpush_vapid_private_key', @vapid_private_key :: text)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key;
-- name: GetWebpushVAPIDKeys :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key,
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key;
-- name: GetChatSystemPrompt :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt;
-- GetChatSystemPromptConfig returns both chat system prompt settings in a
-- single read to avoid torn reads between separate site-config lookups.
-- The include-default fallback preserves the legacy behavior where a
-- non-empty custom prompt implied opting out before the explicit toggle
-- existed.
-- name: GetChatSystemPromptConfig :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt,
COALESCE(
(SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'),
NOT EXISTS (
SELECT 1
FROM site_configs
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
) :: boolean AS include_default_system_prompt,
EXISTS (
SELECT 1
FROM site_configs
WHERE key = 'agents_chat_include_default_system_prompt'
) :: boolean AS include_default_system_prompt_set;
-- name: UpsertChatSystemPrompt :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt';
-- name: GetChatPlanModeInstructions :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_plan_mode_instructions'), '') :: text AS plan_mode_instructions;
-- name: UpsertChatPlanModeInstructions :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_plan_mode_instructions', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_plan_mode_instructions';
-- name: GetChatExploreModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_explore_model_override'), '') :: text AS model_config_id;
-- name: UpsertChatExploreModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_explore_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_explore_model_override';
-- name: GetChatGeneralModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_general_model_override'), '') :: text AS model_config_id;
-- name: UpsertChatGeneralModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_general_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_general_model_override';
-- name: GetChatTitleGenerationModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_title_generation_model_override'), '') :: text AS model_config_id;
-- name: UpsertChatTitleGenerationModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override';
-- name: GetChatCompactionModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_compaction_model_override'), '') :: text AS model_config_id;
-- name: UpsertChatCompactionModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_compaction_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_compaction_model_override';
-- name: GetChatDesktopEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop;
-- name: UpsertChatDesktopEnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_desktop_enabled',
CASE
WHEN sqlc.arg(enable_desktop)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(enable_desktop)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_desktop_enabled';
-- GetChatAdvisorConfig returns the deployment-wide runtime configuration
-- for the experimental chat advisor as a JSON blob. Callers unmarshal the
-- result into codersdk.AdvisorConfig. Returns '{}' when unset so zero
-- values apply by default.
-- name: GetChatAdvisorConfig :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_advisor_config'), '{}') :: text AS advisor_config;
-- UpsertChatAdvisorConfig stores the deployment-wide runtime configuration
-- for the experimental chat advisor. Callers marshal codersdk.AdvisorConfig
-- to JSON before invoking this query.
-- name: UpsertChatAdvisorConfig :exec
INSERT INTO site_configs (key, value) VALUES ('agents_advisor_config', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_advisor_config';
-- name: GetChatComputerUseProvider :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_computer_use_provider'), '') :: text AS provider;
-- name: UpsertChatComputerUseProvider :exec
INSERT INTO site_configs (key, value) VALUES ('agents_computer_use_provider', sqlc.arg(provider))
ON CONFLICT (key) DO UPDATE SET value = sqlc.arg(provider) WHERE site_configs.key = 'agents_computer_use_provider';
-- GetChatDebugLoggingAllowUsers returns the runtime admin setting that
-- allows users to opt into chat debug logging when the deployment does
-- not already force debug logging on globally.
-- name: GetChatDebugLoggingAllowUsers :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_allow_users'), false) :: boolean AS allow_users;
-- UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that
-- allows users to opt into chat debug logging.
-- name: UpsertChatDebugLoggingAllowUsers :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_debug_logging_allow_users',
CASE
WHEN sqlc.arg(allow_users)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(allow_users)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_debug_logging_allow_users';
-- GetChatPersonalModelOverridesEnabled returns whether users may configure
-- personal chat model overrides. It defaults to false when unset.
-- name: GetChatPersonalModelOverridesEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_personal_model_overrides_enabled'), false) :: boolean AS enabled;
-- UpsertChatPersonalModelOverridesEnabled updates whether users may configure
-- personal chat model overrides.
-- name: UpsertChatPersonalModelOverridesEnabled :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_personal_model_overrides_enabled',
CASE
WHEN sqlc.arg(enabled)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(enabled)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_personal_model_overrides_enabled';
-- GetChatIncludeDefaultSystemPrompt preserves the legacy default
-- for deployments created before the explicit include-default toggle.
-- When the toggle is unset, a non-empty custom prompt implies false;
-- otherwise the setting defaults to true.
-- name: GetChatIncludeDefaultSystemPrompt :one
SELECT
COALESCE(
(SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'),
NOT EXISTS (
SELECT 1
FROM site_configs
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
) :: boolean AS include_default_system_prompt;
-- name: UpsertChatIncludeDefaultSystemPrompt :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_include_default_system_prompt',
CASE
WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_include_default_system_prompt';
-- name: GetChatWorkspaceTTL :one
-- Returns the global TTL for chat workspaces as a Go duration string.
-- Returns "0s" (disabled) when no value has been configured.
SELECT
COALESCE(
(SELECT value FROM site_configs WHERE key = 'agents_workspace_ttl'),
'0s'
)::text AS workspace_ttl;
-- name: UpsertChatWorkspaceTTL :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_workspace_ttl', @workspace_ttl::text)
ON CONFLICT (key) DO UPDATE
SET value = @workspace_ttl::text
WHERE site_configs.key = 'agents_workspace_ttl';
-- name: GetChatRetentionDays :one
-- Returns the chat retention period in days. Chats archived longer
-- than this and orphaned chat files older than this are purged by
-- dbpurge. Returns 30 (days) when no value has been configured.
-- A value of 0 disables chat purging entirely.
SELECT COALESCE(
(SELECT value::integer FROM site_configs
WHERE key = 'agents_chat_retention_days'),
30
) :: integer AS retention_days;
-- name: UpsertChatRetentionDays :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_chat_retention_days', CAST(@retention_days AS integer)::text)
ON CONFLICT (key) DO UPDATE SET value = CAST(@retention_days AS integer)::text
WHERE site_configs.key = 'agents_chat_retention_days';
-- name: GetChatDebugRetentionDays :one
-- Chat debug run retention window in days. 0 disables.
SELECT COALESCE(
(SELECT value::integer FROM site_configs
WHERE key = 'agents_chat_debug_retention_days'),
@default_debug_retention_days::integer
) :: integer AS debug_retention_days;
-- name: UpsertChatDebugRetentionDays :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_chat_debug_retention_days', CAST(@debug_retention_days AS integer)::text)
ON CONFLICT (key) DO UPDATE SET value = CAST(@debug_retention_days AS integer)::text
WHERE site_configs.key = 'agents_chat_debug_retention_days';
-- name: GetChatAutoArchiveDays :one
-- Auto-archive window in days. 0 disables.
SELECT COALESCE(
(SELECT value::integer FROM site_configs
WHERE key = 'agents_chat_auto_archive_days'),
@default_auto_archive_days::integer
) :: integer AS auto_archive_days;
-- name: UpsertChatAutoArchiveDays :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_chat_auto_archive_days', CAST(@auto_archive_days AS integer)::text)
ON CONFLICT (key) DO UPDATE SET value = CAST(@auto_archive_days AS integer)::text
WHERE site_configs.key = 'agents_chat_auto_archive_days';