feat(retrieval): the operator can see what was tuned, and every write is recorded (#4102)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Canceled after 31s

The other half of the bargain in milestone 416 step 4. The model moves
these dials; this is what makes that reviewable rather than merely
automatic.

THE HOLE THIS CLOSES

Every retrieval floor is an ordinary settings key, and `/api/settings`
accepts any key at all. A floor written through it landed correctly and
recorded nothing — a tuning history with holes in it, which is worse
than no history because it reads as complete.

So the generic endpoint now routes registry-owned keys through
`set_dial` instead of writing them as plain rows. ROUTED, not refused:
refusing would only work for callers that had been updated, while this
way the form, a script, and an old client all leave the trail, and
there is no version of "forgot to use the other endpoint". Clearing a
control is written as an explicit set back to the shipped default,
because the operator reverting something is the single most important
move this history can record.

`set_dial` now also refuses to record a no-op. The Settings form
re-sends every field on every save, so without that one press of Save
would write six rows saying the operator set six dials to the numbers
they were already on — and a history nobody can skim is one nobody
reads.

WHAT THE OPERATOR GETS

`/api/retrieval/surfaces`, `/surfaces/<name>` and `/tuning-history`,
with `actor` fixed server-side rather than taken from the payload: a
payload-supplied actor would let a model claim to be the operator, and
"did I do this, or did the session?" is the first question this list
is asked.

In Settings: the five missing BUDGETS (until now only auto-inject had
one, so the only control over a noisy surface was to raise its bar —
which discards that surface's best candidates along with its worst),
and a "What has been tuned" panel showing each change, who made it, and
the reason given. The operator's own changes are marked.

The MCP tool demands a reason; these endpoints do not. That asymmetry
is deliberate and stated in routes/retrieval.py: the requirement exists
to make the MODEL read the records before moving a number on someone
else's behalf, and the operator is that someone — a mandatory
justification box on every control would be friction charged to the one
participant who owes no explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-17 11:36:22 -04:00
co-authored by Claude Opus 5
parent 25bd6742e0
commit 6240652dce
7 changed files with 582 additions and 0 deletions
+36
View File
@@ -9,6 +9,8 @@ from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.services.retrieval_surfaces import dial_for_key, get_surface
from scribe.services.retrieval_tuning import set_dial
from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
@@ -25,6 +27,13 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
# What the tuning history records for a dial changed through this form. The MCP
# tool refuses a blank reason; the operator is not asked for one, because the
# requirement exists to make the MODEL read the records before moving a number
# on someone else's behalf — and the operator is that someone. See
# routes/retrieval.py, which states the asymmetry in full.
_SETTINGS_FORM_REASON = "Changed in Settings by the operator."
def _masked(settings: dict) -> dict:
return {
@@ -52,6 +61,33 @@ async def update_settings_route():
to_save = {}
for k, v in data.items():
str_v = str(v)
# A retrieval dial is never written as a plain settings row, wherever
# the request came from (#4102). The value would land correctly and the
# tuning history would say nothing happened — and since milestone 416
# those dials are moved by the model on the operator's behalf, a
# history with holes in it is worse than none: it reads as complete.
#
# Routed rather than refused on purpose. Refusing would work only for
# callers that had been updated; this way every caller that ever writes
# one of these keys — this form, a script, an old client — leaves the
# trail, and there is no version of "forgot to use the other endpoint".
dial = dial_for_key(k)
if dial:
surface, which = dial
s = get_surface(surface)
# A CLEARED control means "back to the shipped starting point", and
# that is a change like any other — it is the operator reverting
# something, which is the single most important move this history
# can record. So it is written as an explicit set to the default
# rather than deleted, which would leave the same value behind and
# no record of anyone having chosen it.
default = s.floor_default if which == "floor" else s.budget_default
try:
await set_dial(uid, surface, which, float(str_v or default),
reason=_SETTINGS_FORM_REASON, actor="human")
except (TypeError, ValueError) as e:
return jsonify({"error": f"{k}: {e}"}), 400
continue
# A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == SECRET_MASK: