mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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>
This commit is contained in:
co-authored by
Michael Suchacz
parent
ba5717dc67
commit
d3f08b1983
Generated
+2
-1
@@ -598,7 +598,8 @@ CREATE TYPE resource_type AS ENUM (
|
||||
'user_skill',
|
||||
'ai_gateway_key',
|
||||
'user_ai_budget_override',
|
||||
'oauth2_provider_settings'
|
||||
'oauth2_provider_settings',
|
||||
'chat_instruction_settings'
|
||||
);
|
||||
|
||||
CREATE TYPE shareable_workspace_owners AS ENUM (
|
||||
|
||||
@@ -20,6 +20,17 @@ const (
|
||||
LockIDChatCapacityAdmission
|
||||
)
|
||||
|
||||
// Per-setting advisory lock IDs for the chat instruction settings. These
|
||||
// derive from the exact site_configs key with GenLockID (FNV-1a 64) instead
|
||||
// of the sequential LockID* block above, so writers of different settings
|
||||
// never contend and the IDs cannot collide with any sequentially allocated
|
||||
// lock ID (different derivation space) or with another subsystem's
|
||||
// GenLockID output (the key strings are unique to these settings).
|
||||
var (
|
||||
LockIDChatInstructionSystemPrompt = GenLockID("agents_chat_system_prompt")
|
||||
LockIDChatInstructionPlanMode = GenLockID("agents_chat_plan_mode_instructions")
|
||||
)
|
||||
|
||||
// GenLockID generates a unique and consistent lock ID from a given string.
|
||||
func GenLockID(name string) int64 {
|
||||
hash := fnv.New64()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestChatInstructionLockIDsDistinct proves the per-setting advisory lock IDs
|
||||
// for the chat instruction settings cannot collide with each other or with
|
||||
// any sequentially allocated LockID* constant. The constants are listed
|
||||
// explicitly rather than enumerated programmatically (there is no registry of
|
||||
// iota constants), so a future LockID* addition that collides fails here in
|
||||
// review, not in a production deadlock.
|
||||
func TestChatInstructionLockIDsDistinct(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
generated := map[string]int64{
|
||||
"LockIDChatInstructionSystemPrompt": LockIDChatInstructionSystemPrompt,
|
||||
"LockIDChatInstructionPlanMode": LockIDChatInstructionPlanMode,
|
||||
}
|
||||
|
||||
sequential := map[string]int64{
|
||||
"LockIDDeploymentSetup": LockIDDeploymentSetup,
|
||||
"LockIDEnterpriseDeploymentSetup": LockIDEnterpriseDeploymentSetup,
|
||||
"LockIDDBRollup": LockIDDBRollup,
|
||||
"LockIDDBPurge": LockIDDBPurge,
|
||||
"LockIDNotificationsReportGenerator": LockIDNotificationsReportGenerator,
|
||||
"LockIDCryptoKeyRotation": LockIDCryptoKeyRotation,
|
||||
"LockIDReconcilePrebuilds": LockIDReconcilePrebuilds,
|
||||
"LockIDReconcileSystemRoles": LockIDReconcileSystemRoles,
|
||||
"LockIDBoundaryUsageStats": LockIDBoundaryUsageStats,
|
||||
"LockIDAIProvidersEnvSeed": LockIDAIProvidersEnvSeed,
|
||||
"LockIDChatModelConfigWrites": LockIDChatModelConfigWrites,
|
||||
}
|
||||
|
||||
// The two generated IDs are pairwise distinct.
|
||||
require.NotEqual(t,
|
||||
LockIDChatInstructionSystemPrompt,
|
||||
LockIDChatInstructionPlanMode,
|
||||
"per-setting lock IDs must differ from each other")
|
||||
|
||||
// Neither generated ID collides with any sequential constant.
|
||||
for name, id := range generated {
|
||||
for seqName, seqID := range sequential {
|
||||
require.NotEqualf(t, seqID, id,
|
||||
"%s (%d) collides with sequential constant %s", name, id, seqName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op, enum values can't be dropped.
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE resource_type
|
||||
ADD VALUE IF NOT EXISTS 'chat_instruction_settings';
|
||||
Generated
+4
-1
@@ -3535,6 +3535,7 @@ const (
|
||||
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
|
||||
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
|
||||
ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings"
|
||||
ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
|
||||
)
|
||||
|
||||
func (e *ResourceType) Scan(src interface{}) error {
|
||||
@@ -3609,7 +3610,8 @@ func (e ResourceType) Valid() bool {
|
||||
ResourceTypeUserSkill,
|
||||
ResourceTypeAIGatewayKey,
|
||||
ResourceTypeUserAIBudgetOverride,
|
||||
ResourceTypeOauth2ProviderSettings:
|
||||
ResourceTypeOauth2ProviderSettings,
|
||||
ResourceTypeChatInstructionSettings:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -3653,6 +3655,7 @@ func AllResourceTypeValues() []ResourceType {
|
||||
ResourceTypeAIGatewayKey,
|
||||
ResourceTypeUserAIBudgetOverride,
|
||||
ResourceTypeOauth2ProviderSettings,
|
||||
ResourceTypeChatInstructionSettings,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+10
-4
@@ -25095,12 +25095,18 @@ SELECT
|
||||
WHERE key = 'agents_chat_system_prompt'
|
||||
AND value != ''
|
||||
)
|
||||
) :: boolean AS include_default_system_prompt
|
||||
) :: 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
|
||||
`
|
||||
|
||||
type GetChatSystemPromptConfigRow struct {
|
||||
ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"`
|
||||
IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
|
||||
ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"`
|
||||
IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
|
||||
IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"`
|
||||
}
|
||||
|
||||
// GetChatSystemPromptConfig returns both chat system prompt settings in a
|
||||
@@ -25111,7 +25117,7 @@ type GetChatSystemPromptConfigRow struct {
|
||||
func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatSystemPromptConfig)
|
||||
var i GetChatSystemPromptConfigRow
|
||||
err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt)
|
||||
err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt, &i.IncludeDefaultSystemPromptSet)
|
||||
return i, err
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,12 @@ SELECT
|
||||
WHERE key = 'agents_chat_system_prompt'
|
||||
AND value != ''
|
||||
)
|
||||
) :: boolean AS include_default_system_prompt;
|
||||
) :: 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)
|
||||
|
||||
@@ -49,6 +49,27 @@ type OAuth2ProviderSettings struct {
|
||||
DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"`
|
||||
}
|
||||
|
||||
// ChatInstructionSettings is the auditable shape of the deployment-wide
|
||||
// chat instruction configuration, stored across the
|
||||
// agents_chat_system_prompt, agents_chat_include_default_system_prompt and
|
||||
// agents_chat_plan_mode_instructions site_configs keys. Both the
|
||||
// system-prompt and plan-mode-instructions endpoints audit this one type;
|
||||
// each populates only the fields its endpoint can change.
|
||||
type ChatInstructionSettings struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
// Name identifies which setting an audit row concerns (e.g. "System
|
||||
// prompt"). It is ignored in diffs and set identically on Old and New.
|
||||
Name string `db:"name" json:"name"`
|
||||
SystemPrompt string `db:"system_prompt" json:"system_prompt"`
|
||||
// IncludeDefaultSystemPromptSet records whether the override row
|
||||
// exists, not only its effective value: writing explicit false over a
|
||||
// legacy absent row does not move the effective value but changes
|
||||
// future behavior, so presence must enter the diff.
|
||||
IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"`
|
||||
IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
|
||||
PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"`
|
||||
}
|
||||
|
||||
type Actions []policy.Action
|
||||
|
||||
func (a *Actions) Scan(src interface{}) error {
|
||||
|
||||
Reference in New Issue
Block a user