Files
pad/internal/server/decision_settings.go
T
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00

494 lines
18 KiB
Go

package server
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/PerpetualSoftware/pad/internal/decision"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Instance-admin settings for the decision provider (TASK-3121, unit 6 of
// PLAN-3114).
//
// The stored setting is ONE of three sources handed to decision.ResolveNamed:
// config file, then this, then the environment — later winning per field,
// because the environment overrides the instance-admin setting (ruled day 73
// on PLAN-3114). On Pad Cloud the environment is the ONLY source and the
// setting is read-only.
//
// A write takes effect without a restart by rebuilding the provider and
// swapping the runner (reconfigureDecisions). The tick reads the runner once
// per pass and SetDecisionRunner re-installs the write doors' enqueue
// resolver, so both halves follow the swap.
// platform_settings keys. None is in adminManagedSettings, so the generic
// /admin/settings endpoint can neither read nor write them — in particular it
// can never return the API key.
const (
settingDecisionProvider = "decision_provider" // "typesafe" / "none" / unset
settingDecisionEnabled = "decision_enabled" // "true" / "false" / unset
settingDecisionModel = "decision_model"
settingDecisionAPIKey = "decision_api_key" // encrypted; see store.SetSecretPlatformSetting
)
// maxDecisionModelLen bounds the model pin. Real pins are ~20 bytes
// ("jev-1.13.0"); the bound only stops a pasted blob being stored.
const maxDecisionModelLen = 128
type decisionSettingsState struct {
// mu serialises reconfiguration, so two concurrent writes cannot
// interleave "resolve" and "swap" and leave the older config running.
mu sync.Mutex
file decision.Config
sets *decision.Registry
// backfillChunk and backfillHook are TEST-ONLY seams applied to every
// runner a reconfigure builds (see Runner.SetBackfillSeams).
backfillChunk int
backfillHook func(ctx context.Context, chunk int) error
}
// SetDecisionBase records the config-file source and the question-set
// registry, both fixed for the process lifetime. Call reconfigureDecisions
// (via ConfigureDecisions) afterwards to build the provider.
func (s *Server) SetDecisionBase(file decision.Config, sets *decision.Registry) {
s.decisionSettings.mu.Lock()
defer s.decisionSettings.mu.Unlock()
s.decisionSettings.file = file
s.decisionSettings.sets = sets
}
// ConfigureDecisions resolves the provider from every source, swaps the
// runner, and starts the tick if a provider is now configured. The returned
// error describes a provider that is NAMED but cannot be built; decisions are
// then off, which the caller reports.
func (s *Server) ConfigureDecisions() error {
s.decisionSettings.mu.Lock()
defer s.decisionSettings.mu.Unlock()
return s.reconfigureDecisionsLocked()
}
func (s *Server) reconfigureDecisionsLocked() error {
cfg, _, _ := s.resolveDecisionConfigLocked()
provider, err := decision.New(cfg)
if err != nil {
provider = nil
}
sets := s.decisionSettings.sets
if sets == nil {
provider = nil
}
runner := decision.NewRunner(s.store, provider, sets)
if runner != nil {
// Pace the effort backfill by one tick pass (TASK-3921): the tick's
// own per-pass limit and interval, defaults when not yet started.
s.decisionTick.mu.Lock()
chunk, poll := s.decisionTick.limit, s.decisionTick.interval
s.decisionTick.mu.Unlock()
if chunk == 0 {
chunk = defaultDecisionTickLimit
}
if poll == 0 {
poll = defaultDecisionTickInterval
}
runner.SetBackfillPacing(chunk, poll)
}
if runner != nil && s.decisionSettings.backfillHook != nil {
runner.SetBackfillSeams(s.decisionSettings.backfillChunk, s.decisionSettings.backfillHook)
}
if runner != nil && s.metrics != nil {
m := s.metrics
runner.SetUsageObserver(func(set string, u decision.Usage) {
m.DecisionCallsTotal.WithLabelValues(set).Inc()
m.DecisionTokensTotal.WithLabelValues(set, "input").Add(float64(u.InputTokens))
m.DecisionTokensTotal.WithLabelValues(set, "output").Add(float64(u.OutputTokens))
})
}
// A running backfill belongs to the OLD runner and its provider: cancel it
// first, then start a new one only for a non-nil runner.
s.cancelEffortBackfill()
s.SetDecisionRunner(runner)
if runner != nil {
s.StartDecisionTick()
s.startEffortBackfill(runner)
}
return err
}
// cancelEffortBackfill cancels a running effort backfill. A run cancelled
// before its last chunk writes no marker, so the next run redoes it; one
// cancelled after it may still mark, which is true, since every candidate was
// enqueued.
func (s *Server) cancelEffortBackfill() {
s.decisionTick.mu.Lock()
defer s.decisionTick.mu.Unlock()
if s.decisionTick.backfillCancel != nil {
s.decisionTick.backfillCancel()
s.decisionTick.backfillCancel = nil
}
}
// startEffortBackfill runs the one-shot effort backfill (TASK-3921) off the
// caller's path. It sits here, not at boot, so it covers a provider the admin
// enables LATER as well as one configured at startup; the marker makes every
// call after the first complete one a cheap read. A new start cancels the run
// before it (reconfigure builds a new runner every time), and a failed run,
// or one cancelled before its last chunk, writes no marker and is retried by
// the next reconfigure or restart. It makes no provider call; it only owes jobs, which the tick
// answers under the debounce. The stopped check and bg.Add share one hold of
// decisionTick.mu, so Stop() either precedes both (nothing starts) or follows
// both (bg.Wait drains it and stopDecisionTick cancels it).
func (s *Server) startEffortBackfill(runner *decision.Runner) {
s.decisionTick.mu.Lock()
if s.decisionTick.stopped || runner == nil {
s.decisionTick.mu.Unlock()
return
}
if s.decisionTick.backfillCancel != nil {
s.decisionTick.backfillCancel()
}
ctx, cancel := context.WithCancel(context.Background())
s.decisionTick.backfillCancel = cancel
s.decisionTick.backfillGen++
gen := s.decisionTick.backfillGen
s.bg.Add(1)
s.decisionTick.mu.Unlock()
go func() {
defer s.bg.Done()
defer func() {
cancel()
s.decisionTick.mu.Lock()
if s.decisionTick.backfillGen == gen {
s.decisionTick.backfillCancel = nil
}
s.decisionTick.mu.Unlock()
}()
defer s.recoverSweeper("effort-backfill")
if _, err := runner.BackfillEffort(ctx); err != nil && ctx.Err() == nil {
slog.Error("effort backfill failed; it will be retried on the next restart or settings change", "error", err)
}
}()
}
// resolveDecisionConfigLocked returns the resolved config, the origin of each
// field, and any error reading the stored secret. A stored key that cannot be
// read — it does not decrypt, or the row is not ciphertext at all — is treated
// as absent rather than failing the resolution: the
// other sources still decide, and the error is reported to the admin.
func (s *Server) resolveDecisionConfigLocked() (decision.Config, decision.Origins, error) {
env := decision.NamedConfig{Name: decision.SourceEnv, Config: decision.EnvConfig()}
if s.cloudMode {
cfg, o := decision.ResolveNamed(env)
return cfg, o, nil
}
stored, serr := s.storedDecisionConfig()
cfg, o := decision.ResolveNamed(
decision.NamedConfig{Name: decision.SourceFile, Config: s.decisionSettings.file},
decision.NamedConfig{Name: decision.SourceAdmin, Config: stored},
env,
)
return cfg, o, serr
}
// storedDecisionConfig reads the admin setting as a decision.Config source.
// The enable toggle maps onto Provider: OFF is decision.ProviderNone, which
// (unlike "") overrides a provider the config file names; ON with no stored
// provider means typesafe, the only one; UNSET says nothing.
func (s *Server) storedDecisionConfig() (decision.Config, error) {
vals, err := s.store.GetPlatformSettings()
if err != nil {
return decision.Config{}, err
}
cfg := decision.Config{Model: vals[settingDecisionModel]}
switch vals[settingDecisionEnabled] {
case "false":
cfg.Provider = decision.ProviderNone
case "true":
cfg.Provider = vals[settingDecisionProvider]
if cfg.Provider == "" {
cfg.Provider = decision.ProviderTypesafe
}
default:
cfg.Provider = vals[settingDecisionProvider]
}
key, kerr := s.store.GetSecretPlatformSetting(settingDecisionAPIKey)
if kerr != nil {
return cfg, kerr
}
cfg.APIKey = key
return cfg, nil
}
// decisionSettingsResponse is the GET/PUT body. It carries whether a key is
// set and where it came from — NEVER the key, masked or otherwise: unlike the
// email key's abcd...wxyz mask, nothing here echoes any of its bytes.
type decisionSettingsResponse struct {
// Effective is what the running server uses.
Effective decisionEffective `json:"effective"`
// Stored is the instance-admin setting as saved, whether or not a
// higher-precedence source overrides it.
Stored decisionStored `json:"stored"`
// Env names the fields the environment sets. Each one overrides the
// stored value, and the page disables that input.
Env decisionEnvFields `json:"env"`
// ReadOnly is true on Pad Cloud, where the environment is the only
// source ("configured by the operator").
ReadOnly bool `json:"read_only"`
// Error describes a provider that is configured but cannot be built:
// decisions are off because of it. Empty when healthy.
Error string `json:"error,omitempty"`
// StoredKeyError says the SAVED key cannot be read (not ciphertext, or
// does not decrypt). It is separate from Error because it need not stop
// decisions: a key from the environment or config file may be in force
// instead (codex round 4).
StoredKeyError string `json:"stored_key_error,omitempty"`
}
type decisionEffective struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Model string `json:"model"`
APIKeySet bool `json:"api_key_set"`
}
type decisionStored struct {
Provider string `json:"provider"`
Enabled *bool `json:"enabled"`
Model string `json:"model"`
APIKeySet bool `json:"api_key_set"`
}
type decisionEnvFields struct {
Provider bool `json:"provider"`
APIKey bool `json:"api_key"`
Model bool `json:"model"`
}
func (s *Server) buildDecisionSettingsResponse() (decisionSettingsResponse, error) {
s.decisionSettings.mu.Lock()
defer s.decisionSettings.mu.Unlock()
return s.buildDecisionSettingsResponseLocked()
}
func (s *Server) buildDecisionSettingsResponseLocked() (decisionSettingsResponse, error) {
cfg, origins, serr := s.resolveDecisionConfigLocked()
var resp decisionSettingsResponse
name, model := decision.Describe(cfg)
resp.Effective = decisionEffective{
Enabled: cfg.Enabled(),
Provider: name,
Model: model,
APIKeySet: cfg.APIKey != "",
}
resp.Env = decisionEnvFields{
Provider: origins.Provider == decision.SourceEnv,
APIKey: origins.APIKey == decision.SourceEnv,
Model: origins.Model == decision.SourceEnv,
}
resp.ReadOnly = s.cloudMode
if !s.cloudMode {
vals, err := s.store.GetPlatformSettings()
if err != nil {
return resp, err
}
// A key counts as saved only if it can be READ: a row the store
// refuses (not ciphertext, or does not decrypt) is reported through
// Error, never as a usable saved key (codex round 3).
storedKey, kerr := s.store.GetSecretPlatformSetting(settingDecisionAPIKey)
resp.Stored = decisionStored{
Provider: vals[settingDecisionProvider],
Model: vals[settingDecisionModel],
APIKeySet: kerr == nil && storedKey != "",
}
switch vals[settingDecisionEnabled] {
case "true":
t := true
resp.Stored.Enabled = &t
case "false":
f := false
resp.Stored.Enabled = &f
}
}
if serr != nil {
// The store error names the failure (not ciphertext, or does not
// decrypt), never the key, but it is not the admin's vocabulary.
slog.Error("decision settings: stored API key unreadable", "error", serr)
resp.StoredKeyError = "The saved API key could not be read. Enter it again to replace it."
}
if _, err := decision.New(cfg); err != nil {
resp.Error = err.Error()
}
return resp, nil
}
// handleGetDecisionSettings: GET /api/v1/admin/decision-provider. Admin-only.
func (s *Server) handleGetDecisionSettings(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
resp, err := s.buildDecisionSettingsResponse()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load decision settings")
return
}
writeJSON(w, http.StatusOK, resp)
}
// decisionSettingsInput is the PUT body. Every field is optional; an absent
// field is left as stored. api_key is write-only: a non-empty value replaces
// the stored key, and clear_api_key removes it.
type decisionSettingsInput struct {
Provider *string `json:"provider"`
Enabled *bool `json:"enabled"`
Model *string `json:"model"`
APIKey *string `json:"api_key"`
ClearAPIKey bool `json:"clear_api_key"`
}
// handleUpdateDecisionSettings: PUT /api/v1/admin/decision-provider.
// Admin-only; refused on Pad Cloud. A field the environment sets is REFUSED
// rather than stored: the write could not take effect, and a 200 would tell
// the admin it had.
func (s *Server) handleUpdateDecisionSettings(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
if s.cloudMode {
writeError(w, http.StatusForbidden, "managed_by_operator", "The decision provider is configured by the operator on this instance.")
return
}
var in decisionSettingsInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if in.APIKey != nil && in.ClearAPIKey {
writeError(w, http.StatusBadRequest, "bad_request", "api_key and clear_api_key cannot both be set")
return
}
var provider, model, key string
if in.Provider != nil {
provider = strings.ToLower(strings.TrimSpace(*in.Provider))
if provider != decision.ProviderTypesafe && provider != decision.ProviderNone {
writeError(w, http.StatusBadRequest, "bad_request", "provider must be \"typesafe\" or \"none\"")
return
}
}
if in.Model != nil {
model = strings.TrimSpace(*in.Model)
if len(model) > maxDecisionModelLen {
writeError(w, http.StatusBadRequest, "bad_request", "model is too long")
return
}
}
if in.APIKey != nil {
key = strings.TrimSpace(*in.APIKey)
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "api_key is empty; use clear_api_key to remove the stored key")
return
}
}
env := decision.EnvConfig()
var envSet []string
if (in.Provider != nil || in.Enabled != nil) && env.Provider != "" {
envSet = append(envSet, "provider")
}
if (in.APIKey != nil || in.ClearAPIKey) && env.APIKey != "" {
envSet = append(envSet, "api_key")
}
if in.Model != nil && env.Model != "" {
envSet = append(envSet, "model")
}
if len(envSet) > 0 {
writeError2(w, http.StatusConflict, "set_by_environment",
"Set by the server environment, which overrides this setting: "+strings.Join(envSet, ", "),
map[string]interface{}{"fields": envSet})
return
}
s.decisionSettings.mu.Lock()
defer s.decisionSettings.mu.Unlock()
var changed []string
// The secret first: it is the only write that can be refused for a
// reason other than a store failure, and refusing it after the others
// landed would leave a half-applied save.
if in.APIKey != nil {
if err := s.store.SetSecretPlatformSetting(settingDecisionAPIKey, key); err != nil {
if errors.Is(err, store.ErrEncryptionUnavailable) {
writeError(w, http.StatusConflict, "encryption_unavailable", "This server has no encryption key (PAD_ENCRYPTION_KEY), so it cannot store an API key. Set PAD_TYPESAFE_API_KEY in the environment instead.")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save the API key")
return
}
changed = append(changed, settingDecisionAPIKey)
} else if in.ClearAPIKey {
if err := s.store.DeletePlatformSetting(settingDecisionAPIKey); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the API key")
return
}
changed = append(changed, settingDecisionAPIKey)
}
if in.Provider != nil {
if err := s.store.SetPlatformSetting(settingDecisionProvider, provider); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save the provider")
return
}
changed = append(changed, settingDecisionProvider)
}
if in.Enabled != nil {
v := "false"
if *in.Enabled {
v = "true"
}
if err := s.store.SetPlatformSetting(settingDecisionEnabled, v); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save the enable toggle")
return
}
changed = append(changed, settingDecisionEnabled)
}
if in.Model != nil {
var err error
if model == "" {
err = s.store.DeletePlatformSetting(settingDecisionModel)
} else {
err = s.store.SetPlatformSetting(settingDecisionModel, model)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save the model")
return
}
changed = append(changed, settingDecisionModel)
}
if err := s.reconfigureDecisionsLocked(); err != nil {
// Saved, but the result names a provider that cannot be built (for
// example enabled with no key). Decisions are off; the response's
// error field says why, which is where the page reads it.
slog.Warn("decision provider misconfigured after settings change; decisions are OFF", "error", err)
}
if len(changed) > 0 {
s.logAuditEvent(models.ActionSettingsChanged, r, settingsChangedMeta(changed))
}
resp, err := s.buildDecisionSettingsResponseLocked()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load decision settings")
return
}
writeJSON(w, http.StatusOK, resp)
}