feat: audit operational agent settings changes by mafredri · Pull Request #27675 · coder/coder · GitHub
Skip to content

feat: audit operational agent settings changes - #27675

Closed
mafredri wants to merge 8 commits into
mainfrom
mathias/codagt-720-audit-operational-agent-settings-changes
Closed

feat: audit operational agent settings changes#27675
mafredri wants to merge 8 commits into
mainfrom
mathias/codagt-720-audit-operational-agent-settings-changes

Conversation

@mafredri

@mafredri mafredri commented Jul 30, 2026

Copy link
Copy Markdown
Member

What

Wires the seven deployment-wide operational chat settings endpoints into the enterprise audit log under one grouped chat_operational_settings resource type whose diff names exactly the changed setting (CODAGT-720, deployment-stable slice; the settings-singleton recipe ratified by S1 in #27668). Every row also names its setting as the audit resource target, so "who changed retention?" is answered by the row, not just the diff.

Endpoint site_configs key Struct field Audit target
PUT /api/experimental/chats/config/retention-days agents_chat_retention_days chat_retention_days Chat retention days
PUT /api/experimental/chats/config/debug-retention-days agents_chat_debug_retention_days chat_debug_retention_days Debug retention days
PUT /api/experimental/chats/config/auto-archive-days agents_chat_auto_archive_days chat_auto_archive_days Auto-archive days
PUT /api/experimental/chats/config/workspace-ttl agents_workspace_ttl workspace_ttl Workspace TTL
PUT /api/experimental/chats/config/computer-use-provider agents_computer_use_provider computer_use_provider Computer-use provider
PUT /api/experimental/chats/config/debug-logging agents_chat_debug_logging_allow_users debug_logging_allow_users Debug-logging allow-users
PUT /api/experimental/chats/config/personal-model-overrides agents_chat_personal_model_overrides_enabled personal_model_overrides_enabled Personal model overrides

How

Each of the seven PUT handlers runs one transaction shaped exactly like main's write. Inside it, a per-setting advisory lock is taken first (its wait bounded by a service-owned 5s context deadline, so a stuck sibling holder surfaces a service 500 rather than an unbounded wait against the client deadline), the handler's own site_configs key is read raw (Old), the existing upsert runs byte-identical, and Old/New are populated only when the stored text actually changed. New is the value just written (the upserts store the request value verbatim), so there is no post-write read to fail after the write has already succeeded. A value-identical PUT stages a cancellation and applies it only after the transaction commits. Only the handler's own field is populated on both sides, so a single-setting change diffs exactly one field and never reads or emits unrelated settings. Each setting is described once by a chatOperationalSetting value pairing its storage key, its human-readable audit target, and its per-setting lock ID, so a key, its label, and its lock cannot drift apart; the target rides on the struct as an ActionIgnore field, naming the row without entering the diff.

The Old capture is best effort: it runs before anything is written, so on failure the transaction rolls back and the plain write runs exactly as main does (its error, if any, answers exactly as main's would, with no entry). A genuine write failure is fatal, as on main. The new key-parameterized GetChatSiteConfigValue query is restricted to the agents_ namespace in SQL and pins its dbauthz contract to ResourceDeploymentConfig: update. The per-setting lock derives from the exact key with GenLockID (FNV-1a 64) instead of the sequential LockID* block, so writers of different settings never contend and the IDs cannot collide with the sequential block or another subsystem's GenLockID output; TestChatOperationalLockIDsDistinct proves it.

Behavior preservation (invariant 2)

One transaction per request shaped like main's; no post-write read, no fallback to a separate direct-write path after a write. A genuine write failure reproduces main's 500 and bare Detail exactly (the write error is recorded in a local variable and reported directly). Every failure position was measured against an unmodified origin/main control with separate columns:

Position Client status Client body (Detail) Persisted Entries Entry status Entry diff
Success 204 - yes 1 204 real Old-to-New
No-op (value-identical) 204 - yes 0 - -
Old-read failure 204 - yes (plain write) 0 - -
Write failure 500 bare write error rolled back 0 - -
Plain-write fallback failure 500 bare write error rolled back 0 - -
Lock failure (or lock wait > 5s) 500 lock error rolled back 0 - -
Begin / rollback / commit / post-commit failure 500 tx error rolled back 0 - -

All member-visible columns (status, body Message and Detail, persisted state) match main on all seven endpoints.

Concurrency, run against real PostgreSQL with a barrier forcing both transactions to read before either commits. Without the per-key lock (mutation), an existing row with two concurrent different-valued writes records both from the same stale baseline (2 entries, last-writer-wins persisted); with the lock, the second blocks on the first's lock, reads the first's committed value, and records the correct transition. Two concurrent identical writes on an existing row produce one entry (the second sees the first's committed value and cancels), which the lock-free baseline emits twice. Removing the lock turns the existing-row suppression into a stale-Old duplicate, mutation-proven.

Review findings closed

  • Post-write New-read failure losing a committed write (incl. client cancellation, since InTx begins with context.Background()): closed by deleting the post-write read and setting New from the value written.
  • Old-read failure aborting or rolling back a write: closed by best-effort Old capture (roll back, plain write, no entry, warn).
  • No-op cancellation depending on the value matching: closed by staging commitAudit(false) until after the transaction commits.
  • Duplicate entries from concurrent identical writes: prevented for existing rows by the per-key lock; documented as possible for concurrent first materialization.

Known limits (deliberate, per plan D5 and the ratified write structure)

  • No history-by-setting via resource ID. Rows carry a per-request artificial ID, not a stable per-setting ID, so the audit page cannot list one setting's history by resource ID. The per-setting target names each row, but correlation by ID is not available.
  • No record of denied attempts. A rejected PUT (RBAC) emits nothing, so there is no row showing someone was denied.
  • Concurrent first writes to a not-yet-existing setting row can each record an empty Old, and two identical first writes can each emit an entry. With no row present there is nothing to lock against; these seven keys start absent until first written, so this is not a corner case.
  • The added lock introduces one theoretical failure position: a lock wait exceeding the bounded 5s deadline, whose only holders are sibling requests holding it for the duration of a single upsert. The wait is service-bounded so it surfaces a 500 the deployment owns rather than an unbounded wait against the client deadline.

The audit filter label for the new type is added to the hand-maintained exceptions in AuditFilter.tsx so it renders "Chat Operational Settings" instead of the capitalized raw value.

Tests

  • One WriteAndSuppression table across all seven endpoints: Write entry on change with the per-setting target, no-op suppression on a value-identical PUT (canceled via InitRequestWithCancel), the storage-level boundary (a row-materializing PUT of the effective fallback default audits as empty-to-value; an identical PUT on an existing row emits nothing), and a validation-rejected PUT emits nothing.
  • SingleSettingDiffNamesExactlyOneField: a single-setting change diffs exactly one field even when other operational settings hold stored values (mock-only audit.NewMockWithDiffFn).
  • MalformedSettingRepair: seed junk text into a key, PUT a valid value, assert the 204 and repaired write are unchanged and the committed diff is exactly the junk-to-valid transition (mutation-proven).
  • NonAgentsKeyReadsEmpty: the agents_ SQL constraint. TestChatOperationalSettingPanicsOnUnknownKey: the descriptor's settings method panics on an unknown key. TestChatOperationalLockIDsDistinct: the per-key lock IDs are pairwise distinct and distinct from every enumerated sequential lock ID.
  • ConcurrentIdenticalPUTsSingleEntry: the per-key lock serializes concurrent identical PUTs to one entry (race clean); removing it turns suppression into a stale-Old duplicate, mutation-proven.
  • enterprise/audit/diff_internal_test.go: single-field diff, malformed-old tracked as raw text, artificial-ID-ignored. dbauthz MethodTestSuite entry for the raw read query; TestAuditableResources; TestAuditDBEnumsCovered; migration suites.

Full local gate green: make fmt, make lint, make -B gen zero-diff, TestChatOperationalSettingsAudit (13, incl. -race), TestChatOperationalSettingPanicsOnUnknownKey, TestChatOperationalLockIDsDistinct, enterprise/audit package, dbauthz, coderd/database/migrations, frontend lint/format, full ./coderd package.

Exclusions (named per the plan)

  • The four model-override contexts (general, explore, title-generation, compaction) and the advisor endpoint (PUT .../config/advisor): deferred to S3 (post-M5, decision D3); advisor Enabled is experiment-derived and read-only on main.
  • agents_desktop_enabled: no HTTP handler exists on main, so there is nothing to audit.
  • agents_template_allowlist: being deleted by CODAGT-74's T2; Ethan's track audits templates.agents_allowed instead.
  • All member-scope endpoints sharing the /chats/config route block (user-personal-model-overrides, user-debug-logging, user-prompt, user-compaction-thresholds): per-user actions, not admin config.
  • Chat usage limits: out of scope; the native surface is being removed by the cost-control unification (CODAGT-782) and its replacement (AI Gateway budgets) is already audited.

Plan entry

S2 plan entry (CODAGT-66 PLAN.md)

S2 feat: audit operational agent settings changes (CODAGT-720, deployment-stable slice; base: main; starts after S1 recipe ratified)

  • Struct (decision D1): database.AgentsOperationalSettings{ID uuid.UUID; ChatRetentionDays, ChatDebugRetentionDays, ChatAutoArchiveDays, WorkspaceTTL, ComputerUseProvider, DebugLoggingAllowUsers, PersonalModelOverridesEnabled string}: every field a raw-text mirror of its site_configs.value (D1 rationale; survey table in agents-surface.md section 4 lists keys/queriers/handlers/routes for all seven). One resource_type agents_operational_settings, AuditActionMap Write-only, all fields ActionTrack, id ActionIgnore.
  • Same registration/migration/codersdk/docs cycle as S1 (one enum value, number picked at push).
  • Wiring the seven PUT handlers (putChatRetentionDays, putChatDebugRetentionDays, putChatAutoArchiveDays, putChatWorkspaceTTL, putChatComputerUseProvider, putChatDebugLogging, putChatPersonalModelOverridesAdminSettings): each handler wraps the RAW read of its OWN key (the new key-parameterized raw query from D1; absent row = empty string; cannot fail on malformed text) + its existing upsert in one InTx (behavior-preserving: same single write, same response, malformed stored text still gets repaired exactly as today) and populates ONLY its own field on both Old (previous raw text) and New (the exact text just written); all other fields stay zero on both sides, so they never diff and no handler gains reads of, or failure modes from, unrelated settings (the adversarial review killed both the all-seven snapshot and typed-getter Old capture). Artificial ID set on New only after the tx succeeds and only when the raw text changed; no-op suppression identical to S1. EXCLUDED and named as exclusions in the PR body: the four model-override contexts and the advisor endpoint (S3, post-M5, decision D3), agents_desktop_enabled (no handler exists), agents_template_allowlist (deleted by 74's T2; Ethan's track audits templates.agents_allowed instead), all member-scope endpoints sharing the route block.
  • Tests: per-handler Write + no-op suppression assertions (one test table across the seven, incl. the storage-level no-op boundary: identical PUT on an existing row emits nothing; row-materializing PUT of the default value emits the empty-to-value entry); a malformed-own-setting repair test (seed junk text into a key, PUT a valid value, assert today's response/write semantics are unchanged AND the audit diff shows junk-to-valid); diff test proving a single-setting change diffs exactly one field and that untouched-but-populated settings elsewhere in the deployment never appear; MethodTestSuite entry for the raw read query (update-contract check); enterprise/audit/diff_internal_test.go entry.
  • Bookkeeping at PR open: correct CODAGT-720's text (desktop toggle nonexistent; overrides/advisor deferred to S3 with the M5 rationale; usage limits handled by CODAGT-782's removal).
  • Review focus: raw-text Old/New construction (populated-both-sides-equal for untouched fields; raw reads never block or reshape the write path; the repair semantics test); the exclusion list's completeness; zero new reads of unrelated settings.

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

Wire the seven deployment-wide operational agents settings endpoints
(chat retention days, debug retention days, auto-archive days, workspace
TTL, computer-use provider, debug-logging allow-users, and
personal-model-overrides-enabled) into the enterprise audit log under one
grouped agents_operational_settings resource whose diff names exactly the
changed setting.

Each PUT handler wraps a best-effort raw read of its own site_configs key
and its existing upsert in one transaction, so the write path is
byte-identical and malformed stored text still gets repaired. A shared
helper holds the LockIDChatSettingsWrites advisory lock, captures Old and
New only when the stored text actually changed, and suppresses the entry
on no-op or capture failure, so concurrent identical PUTs emit one entry.

The new key-parameterized GetChatSiteConfigValue query returns the raw
stored text (empty when the row is absent) and cannot fail on malformed
text, keeping the read-first Old capture from changing behavior. Its
dbauthz contract pins to ResourceDeploymentConfig: update, the same
permission the PUTs gate on.

CODAGT-720
@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

CODAGT-720

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

mafredri added 6 commits July 30, 2026 10:28
Round 1 of adversarial review on PR #27675.

The added InTx wrapper changed every database-error response body: the
upsert's original error came back as "execute transaction: <err>" instead
of "<err>". Each handler now records the write callback's error in a
local and reports that error directly, so the write-failure response body
is byte-identical to main on all seven endpoints; transaction-level
failures (begin, commit, the advisory lock) still surface through the
InTx wrapper so they stay distinguishable. WriteFailureDetailMatchesMain
pins the Detail for one endpoint, mutation-proven by restoring the
wrapper; the seven-endpoint injection table is byte-identical to an
unmodified origin/main control.

Two claimed tests now pin what they claim. MalformedSettingRepair uses
the mock-only NewMockWithDiffFn and asserts the committed diff is exactly
the malformed old text to the repaired value; NewCaptureDegradesViaWarn
exercises the new-capture failure branch (204, write persists, zero
entries, degrade warn). Both are mutation-proven.

CODAGT-720
Round 2 prep on PR #27675.

Renumber the enum migration to 000562 after main took 000561 (content
unchanged, enum-only).

Restrict GetChatSiteConfigValue to the agents_ key namespace in SQL so a
future caller cannot read unrelated site_configs secrets (derp_mesh_key,
webpush keys) through it, bypassing their narrower dedicated-getter
gates. NonAgentsKeyReadsEmpty pins the excluded case and that the seven
agents_ keys still read exactly.

operationalSettingsForKey now panics on an unknown key, matching the
audit package's switch convention, instead of silently returning an
all-zero struct that would suppress the entry for a setting wired without
updating the switch. TestOperationalSettingsForKeyPanicsOnUnknownKey pins
the panic and the happy path.

CODAGT-720
Round 3 on PR #27675.

Part A: the audit transaction and advisory lock changed what a member
sees on failure. Each handler now runs the audited path (transaction,
lock, Old capture, write, New re-read, comparison) and, on any
transaction-level or audited-write failure, falls back to the direct
write (what main does today) and derives the response from it, emitting
no diff. The full InTx error is logged before the response detail is
selected so rollback detail never reaches the body. Measured against an
unmodified origin/main control: lock, write, Old-read, and New-read
failures all return 204, persist the value, and emit no entry on all
seven endpoints, identical to main's plain write. Fallback regression
tests are mutation-proven.

Part B (plan D5, S2 slice): rename the resource type to
chat_operational_settings and give each row a per-setting audit resource
target. The target rides on the struct as an ActionIgnore name field,
derived from the same chatOperationalSetting descriptor as the storage
key so a key and its label cannot drift. The audit filter label is added
to the hand-maintained exceptions in AuditFilter.tsx. Stable per-setting
IDs, denied-attempt rows, and degraded-capture markers are deliberately
out of scope here and land in S1.

CODAGT-720
Round 3 follow-up on PR #27675.

The post-commit-after-callback position already emits the ordinary entry
on this head: the fallback repeats the idempotent upsert, so client
status, entry StatusCode, and the real Old-to-New diff agree (204, 204,
real diff). PostCommitFallbackEmitsOrdinaryEntry pins the triple via a
commit-failing driver, and removing the keep-Old-New-across-the-fallback
behavior is mutation-proven.

DivergentValueFallbackBaseline covers a client retrying with a corrected
value after a failed audited write: the fallback persists the new value
with no entry, and the next audited change diffs from the
fallback-persisted baseline, not from any pre-failure value.

CODAGT-720
Round 4 on PR #27675, implementing the ratified audit-write structure.

Delete the fallback structure entirely: no retry, no direct-write path,
no failure classification, no degraded rows. Each handler runs one
transaction shaped exactly like main's write. The Old and New captures
are fatal on error, as in usersecrets.go: the raw read cannot fail
independently of the write it shares a connection with, so a failure is a
real database failure and the request fails exactly as main's write
would. This also settles the cross-unit inconsistency: a genuine write
failure now reproduces main's 500 instead of falling back.

Replace the global LockIDChatSettingsWrites with seven per-setting
advisory locks derived from the exact site_configs key with GenLockID, so
writers of different settings never contend and the IDs cannot collide
with the sequential LockID block or another subsystem's GenLockID output.
The per-key lock covers the absent-row first-write case that FOR UPDATE
cannot, and removes cross-setting contention.

No-op suppression moves to InitRequestWithCancel, taken inside the
transaction and applied after it returns. The rename, per-setting
identity and targets, raw storage text, and the agents_ query constraint
are unchanged.

CODAGT-720
Round 4 follow-up on PR #27675.

The fallback deletion took the round-1 write-error detail parity with it,
but that fix was orthogonal to the fallback. Restore it: the write error
is recorded in a local variable inside the callback and reported directly
after InTx returns non-nil, so the write-failure Detail is the bare error
main returns on all seven endpoints. The transaction error stays for
begin, lock, commit, and rollback failures, which main never reaches.
S1's system-prompt endpoint is transactional on main and keeps the
wrapped detail; plan-mode and these seven are not transactional on main
and return the bare error.

Add TestChatOperationalLockIDsDistinct: the seven GenLockID-derived
advisory lock IDs are asserted pairwise distinct and distinct from every
enumerated sequential LockID* constant, replacing a probability argument
with a fact. The sequential list is explicit so a future added constant
is caught by review, not a production deadlock.

CODAGT-720

Copy link
Copy Markdown
Member Author

Status, 30 July 2026: not ready to merge

Handing this over. CI is green on the current head, but there is a known defect that must be fixed first, shared with #27668.

Defect: the handler re-reads the stored value after writing it, to record the new value. If that read fails while the transaction is still healthy, the request returns 500 and rolls back a write that already succeeded. Client cancellation is enough: transactions here are started with a background context, so cancelling a request cancels the individual query and not the transaction. On an unmodified build the write survives and only the response is lost, so this can silently discard an administrator's change.

Fix: delete the post-write read and compute the new value from what was written; these upserts store the request value verbatim.

Also outstanding:

  • Call the audit cancellation for a no-op only after the transaction commits, so attempt logging does not depend on whether the value happened to match.
  • Log the full transaction error before selecting the response detail, so a rollback failure is not lost from response and logs at once.
  • Give lock acquisition a bound owned by the service rather than relying on the client's deadline.
  • A review pass over failure-position parity and concurrency was still running when this was written.

What is sound: all seven endpoints wired to one resource type, each row naming its own setting through a descriptor that pairs the storage key with its label so the two cannot drift, raw stored text as the audited value with a test proving a malformed value's repair is recorded honestly, the new key-scoped read query restricted in SQL to the agents_ namespace so it cannot read unrelated secrets, and a panic on an unknown setting key so a future eighth setting cannot be wired without being audited.

Deliberate limits, documented in the description: no per-setting history by resource id, no entries for denied attempts, and no marker when capture degrades. The sibling instruction PR carries those; this unit does not, because the ticket ranks these settings lower priority.

Context and the wider plan: see the "Agents audit logging: handover" document on CODAGT-66.

🤖 This review was generated with the help of Coder Agents.

Round 4 correction on PR #27675 (the fatal-read premise was falsified).

Delete the post-write read and set New from the value written (the
upserts store the request value verbatim), removing the data-loss path
where a New-read failure, including client cancellation since InTx begins
with context.Background(), makes the handler 500 and rolls back a write
that already succeeded.

Old-capture failure becomes best effort and safe: it runs before anything
is written, so on error the transaction rolls back, the plain write runs
exactly as main does, no entry is emitted, and the plain write's error
(if any) answers exactly as main's would.

Stage the no-op decision: set a local inside the callback and call
commitAudit(false) only after InTx returns nil, so whether an attempt is
logged no longer depends on whether the value happened to match.

Bound the lock wait with a service-owned 5s context deadline instead of
relying on the client deadline, and log the full InTx error before the
response detail is selected so a rollback layered on a write failure is
not lost from both response and logs.

Keep the per-setting lock, the rename, the per-setting targets, raw
stored text, the agents_ query constraint, the unknown-key panic, and the
write-error detail parity. Measured against an unmodified origin/main
control: every member-visible column matches on all seven endpoints.

CODAGT-720

Copy link
Copy Markdown
Member Author

Update, same day: the blocking defect is fixed

New head 539bb2f8ca. The post-write read described in the comment above is gone, so the data-loss path it created is gone with it: the audited new value is now built from what the handler wrote, and nothing reads the database after the write.

Also in this head:

  • An old-value read failure no longer fails the request. It rolls back, performs the plain write exactly as an unmodified build does, and records no entry.
  • The audit cancellation for an unchanged value is applied only after the transaction returns, so whether a failed attempt is recorded no longer depends on whether the submitted value happened to match.
  • Lock acquisition has a bound owned by the service instead of relying on the client's deadline.
  • One further divergence was found while proving the above and fixed: the plain write ran inside the outer transaction, so its error came back wrapped and the response detail stopped matching an unmodified build. The helper now returns that error unwrapped.

Measured against an unmodified main build, all seven endpoints: success 204 with a real diff; identical rewrite 204 with no entry; old-read failure 204, written, no entry; write failure 500 with the bare error detail and nothing persisted; and transaction or lock failures 500 with nothing persisted. Concurrency was checked against real PostgreSQL with a barrier: without the per-setting lock, two concurrent writes to one setting both record the same stale previous value; with it, the second blocks, sees the committed value and suppresses correctly.

One residual behaviour a reviewer should weigh, listed among the decisions needing human sign-off in the handover document: a lock wait exceeding the bound returns 500, where an unmodified build would have completed the write. That is the price of recording an exact before-and-after, and dropping the lock is a legitimate alternative that several existing audited endpoints in this codebase already take.

A review of this head was commissioned but had not reported when this was written, so treat the PR as unreviewed at 539bb2f8ca.

🤖 This review was generated with the help of Coder Agents.

Copy link
Copy Markdown
Member Author

@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 22, 2026
@github-actions github-actions Bot closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant