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:
Mathias Fredriksson
2026-08-18 20:03:06 +02:00
committed by GitHub
co-authored by Michael Suchacz
parent ba5717dc67
commit d3f08b1983
25 changed files with 1697 additions and 43 deletions
+4 -2
View File
@@ -24305,7 +24305,8 @@ const docTemplate = `{
"user_ai_budget_override",
"chat",
"user_secret",
"user_skill"
"user_skill",
"chat_instruction_settings"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@@ -24343,7 +24344,8 @@ const docTemplate = `{
"ResourceTypeUserAIBudgetOverride",
"ResourceTypeChat",
"ResourceTypeUserSecret",
"ResourceTypeUserSkill"
"ResourceTypeUserSkill",
"ResourceTypeChatInstructionSettings"
]
},
"codersdk.Response": {
+4 -2
View File
@@ -22287,7 +22287,8 @@
"user_ai_budget_override",
"chat",
"user_secret",
"user_skill"
"user_skill",
"chat_instruction_settings"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@@ -22325,7 +22326,8 @@
"ResourceTypeUserAIBudgetOverride",
"ResourceTypeChat",
"ResourceTypeUserSecret",
"ResourceTypeUserSkill"
"ResourceTypeUserSkill",
"ResourceTypeChatInstructionSettings"
]
},
"codersdk.Response": {
+17 -2
View File
@@ -42,9 +42,19 @@ func NewMock() *MockAuditor {
return &MockAuditor{}
}
// NewMockWithDiffFn returns a MockAuditor whose entries carry diffs computed
// by the supplied function instead of the default empty diff. Tests use it to
// pin the Old/New pair a handler captured, which the empty diff cannot
// express. The function is test-supplied comparison logic; the mock never
// calls the production differ.
func NewMockWithDiffFn(fn func(old, newVal any) Map) *MockAuditor {
return &MockAuditor{diffFn: fn}
}
type MockAuditor struct {
mutex sync.Mutex
auditLogs []database.AuditLog
diffFn func(old, newVal any) Map
}
// ResetLogs removes all audit logs from the mock auditor.
@@ -70,8 +80,13 @@ func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error {
return nil
}
func (*MockAuditor) diff(any, any) Map {
return Map{}
func (a *MockAuditor) diff(old, newVal any) Map {
a.mutex.Lock()
defer a.mutex.Unlock()
if a.diffFn == nil {
return Map{}
}
return a.diffFn(old, newVal)
}
// Contains returns true if, for each non-zero-valued field in expected,
+2 -1
View File
@@ -42,7 +42,8 @@ type Auditable interface {
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
database.UserSecret |
database.UserSkill
database.UserSkill |
database.ChatInstructionSettings
}
// Map is a map of changed fields in an audited resource. It maps field names to
+26
View File
@@ -158,6 +158,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Name
case database.UserSkill:
return typed.Name
case database.ChatInstructionSettings:
return typed.Name
default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
}
@@ -168,6 +170,22 @@ func ResourceTarget[T Auditable](tgt T) string {
// 51A51C = "Static"
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
// Fixed IDs for the two chat instruction settings. History-by-setting works
// only if every change to one setting carries the same resource ID, so
// unlike the per-write artificial IDs of the other settings singletons,
// these never change. C1A7 = "Chat".
var (
ChatInstructionSystemPromptID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000001")
ChatInstructionPlanModeID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000002")
)
// Human-readable targets for the two chat instruction settings, so an audit
// row names the setting it concerns.
const (
ChatInstructionSystemPromptName = "System prompt"
ChatInstructionPlanModeName = "Plan mode instructions"
)
func ResourceID[T Auditable](tgt T) uuid.UUID {
switch typed := any(tgt).(type) {
case database.Template:
@@ -243,6 +261,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.UserSkill:
return typed.ID
case database.ChatInstructionSettings:
// Fixed ID per setting; see ChatInstructionSettings IDs.
return typed.ID
default:
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
}
@@ -318,6 +339,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeUserSecret
case database.UserSkill:
return database.ResourceTypeUserSkill
case database.ChatInstructionSettings:
return database.ResourceTypeChatInstructionSettings
default:
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
}
@@ -408,6 +431,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.UserSkill:
// User skills are global to the user across organizations.
return false
case database.ChatInstructionSettings:
// Deployment settings, not scoped to any organization.
return false
default:
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
}
+2 -1
View File
@@ -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 (
+11
View File
@@ -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()
+50
View File
@@ -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';
+4 -1
View File
@@ -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,
}
}
+10 -4
View File
@@ -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
}
+6 -1
View File
@@ -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)
+21
View File
@@ -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 {
+231 -8
View File
@@ -4546,12 +4546,38 @@ func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
})
}
// chatInstructionSettingsLockTimeout bounds how long a request waits for the
// per-setting advisory lock. The only other holders are sibling requests
// holding it for a single upsert, so a short bound cannot strand a waiter:
// on expiry the request falls back to the unaudited write path instead of
// hanging until the client deadline.
const chatInstructionSettingsLockTimeout = 5 * time.Second
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionSystemPromptID,
Name: audit.ChatInstructionSystemPromptName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
@@ -4569,7 +4595,40 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
})
return
}
var (
noChange bool
oldCaptured bool
oldReadErr error
lockErr error
)
// The per-setting advisory lock serializes the audit change-detection
// with the write: two concurrent identical PUTs both still succeed,
// but the second transaction's comparison sees the first's committed
// state and reports no change. The lock wait is bounded so a waiter
// cannot hang past the client's deadline.
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil {
lockErr = err
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
// Old capture is best effort: it runs after the lock but before
// the write, so abandoning the transaction on its error costs
// nothing and the handler writes directly below with no entry.
// The lock keeps the captured baseline serialized with the write.
oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx)
if oldErr != nil {
oldReadErr = oldErr
return oldErr
}
oldCaptured = true
aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt
aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet
aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
@@ -4579,17 +4638,83 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
// avoiding a backward-compatibility regression for older clients
// that only send system_prompt.
if req.IncludeDefaultSystemPrompt != nil {
return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil {
return err
}
}
// Derive New from what was written rather than re-reading: the
// upserts store $1 verbatim, so the stored prompt is the request
// value, and the effective include-default flag follows
// GetChatSystemPromptConfig's rule from the written toggle row
// and the written prompt. A post-write read would fail on
// query-local context cancellation even after a successful
// commit, silently discarding a change the client made.
newIncludeSet := oldConfig.IncludeDefaultSystemPromptSet
newIncludeValue := oldConfig.IncludeDefaultSystemPrompt
if req.IncludeDefaultSystemPrompt != nil {
newIncludeSet = true
newIncludeValue = *req.IncludeDefaultSystemPrompt
}
if !newIncludeSet {
// Legacy fallback: a non-empty custom prompt implies opting
// out; otherwise the setting defaults to true.
newIncludeValue = sanitizedPrompt == ""
}
aReq.New.SystemPrompt = sanitizedPrompt
aReq.New.IncludeDefaultSystemPromptSet = newIncludeSet
aReq.New.IncludeDefaultSystemPrompt = newIncludeValue
noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt &&
aReq.New.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet &&
aReq.New.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt
return nil
}, nil)
if err != nil {
auditSetupErr := lockErr
if auditSetupErr == nil {
auditSetupErr = oldReadErr
}
if auditSetupErr != nil {
// The audit-added lock wait or Old capture failed before
// anything was written, so the transaction was abandoned at
// no cost: write directly exactly as main does, emit no
// entry, and warn.
api.Logger.Warn(ctx, "audit change detection failed, writing chat system prompt without an audit entry",
slog.Error(auditSetupErr))
commitAudit(false)
if mainErr := api.Database.InTx(func(tx database.Store) error {
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
if req.IncludeDefaultSystemPrompt != nil {
return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
}
return nil
}, nil); mainErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: mainErr.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
return
}
// This endpoint was transactional on main, so begin, write,
// commit and rollback errors all surface exactly as main's. The
// advisory lock is audit-added, so its failure falls back above
// instead of surfacing.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
}
if oldCaptured && noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
@@ -4620,6 +4745,24 @@ func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionPlanModeID,
Name: audit.ChatInstructionPlanModeName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
@@ -4643,14 +4786,94 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ
return
}
if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: err.Error(),
})
return
}
// This endpoint was not transactional on main, so the audited
// transaction's lock, begin, commit and rollback failures run the
// direct write instead, matching main's non-transactional behavior.
// Only a failure of the write itself surfaces the transaction error
// (the raw write error, as main produced).
var (
noChange bool
oldCaptured bool
oldReadErr error
)
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
var writeErr error
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil {
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
// Old capture is best effort: it runs after the lock but before
// the write, so abandoning the transaction on its error costs
// nothing and the handler writes directly below with no entry;
// see putChatSystemPrompt for why New is derived from the write.
oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx)
if oldErr != nil {
oldReadErr = oldErr
return oldErr
}
oldCaptured = true
aReq.Old.PlanModeInstructions = oldInstructions
if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
writeErr = err
return err
}
aReq.New.PlanModeInstructions = sanitizedInstructions
noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions
return nil
}, nil)
if err != nil {
// Log the full InTx error first: the response below derives from
// the write error alone, so a rollback failure layered on it
// would otherwise vanish from response, audit row and logs
// simultaneously.
api.Logger.Warn(ctx, "plan mode instructions update transaction failed",
slog.Error(err))
if oldReadErr != nil {
// The Old capture failed before anything was written: fall
// through to the direct write below with no entry.
oldCaptured = false
} else if writeErr != nil {
// The write itself failed: respond with the raw error
// exactly as the endpoint did before the audit wiring
// existed.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: writeErr.Error(),
})
return
}
// Only the audit-added transaction machinery failed (lock,
// begin, commit, rollback, or the Old read), not the write.
// Main's non-transactional behavior is authoritative: run the
// direct upsert and derive the response from that.
if mainErr := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); mainErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: mainErr.Error(),
})
return
}
if !oldCaptured {
// The lock wait or Old capture failed, so the write landed
// through the direct path with no baseline: no entry, warn,
// and finish with the success response. A commit failure
// keeps oldCaptured true and deliberately falls through so
// the attempt row with its real diff survives.
api.Logger.Warn(ctx, "audit change detection failed, writing plan mode instructions without an audit entry",
slog.Error(err))
commitAudit(false)
rw.WriteHeader(http.StatusNoContent)
return
}
}
if oldCaptured && noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
File diff suppressed because it is too large Load Diff