feat(autonudge): optional per-loop banner for the visible nudge row - #5999
feat(autonudge): optional per-loop banner for the visible nudge row#5999rnoack1 wants to merge 1 commit into
Conversation
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS The banner itself is sound and well-bounded; the concern is the fleet-wide store/egress machinery riding under it, which dominates the PR and its revert story. Watch
Suggestions
[DESIGN-REVIEWED] 401cba6 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All evidence gathered: the full patch, the intent file, cron's sibling loader contract (cron.py:4083 — a bad entry is warned and "dropped from the store on the next write"), and the dashboard's NudgeCard (the row already collapses to a one-line chip; the body renders only on expand). Here is the review. First-Principles-Verdict: CONCERNS The banner earns its place, but a ~200-line quarantine-sidecar subsystem rides in to save rows this codebase's own sibling contract says to drop. What this change shipsIntent: show one short operator-authored line per nudge cycle instead of re-storing the multi-KB instruction — ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 401cba6 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate dies under falsification:
No new grounded defect surfaced in the changed lines. No findings. [OPUS-REVIEWED] 401cba6 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/autonudge.py:968 -- Failed replacement duplicates a quarantined row |
7688449 to
4279cec
Compare
4279cec to
1ee17ea
Compare
1ee17ea to
bb6e1e7
Compare
bb6e1e7 to
915fa1b
Compare
f178b34 to
4325738
Compare
4325738 to
13e7e5e
Compare
13e7e5e to
a5e12af
Compare
a5e12af to
3f5296c
Compare
|
Full-diff overlap audit after rereading updated head Please integrate the structured-monitor stack first (#5184 → #5185 → #5186 → #5305), then rebase this banner work and decide once whether |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of I have everything I need. The transcript append site confirms the nudge row's UX-Verdict: CONCERNS A banner row is pixel-identical to a real nudge row, so the transcript silently stops showing what the agent was actually told. Watch
Suggestions
[UX-REVIEWED] 401cba6 |
|
Reviewed the full diff as part of a maintainer sweep of 1. Blocking correctness bug in this PR's own new code.
ignored_fields_for_update({"message": None if echoed else body.get("message")})When the client omits The new test file does not exercise it: the test calls the helper with 2. New silent-overwrite path (data loss).
3. Scope, which the description itself leaves open. 6218 additions for an opt-in display field, carrying unrelated There is also a sequencing point: the #5184 to #5186 monitor stack is still open and rewrites the same AutoNudge wire and persistence contract, so landing this first means resolving that overlap twice. I would approve a version of this that is the banner alone, with items 1 and 2 fixed. Auto-merge is not armed here; nothing lands while this is open. |
An unreadable quarantine sidecar fails CLOSED and is MOVED ASIDE: the write raises so the store cannot compact around unenumerated rows, and recovery is a restart, not a repair.
Closing — superseded by your own re-cut, #7777Verified relationship: Both are OPEN, both are exactly ONE commit off the SAME merge-base d402acd (no stacking), and origin/main carries no banner at all: no Carry this over firstThis closure is about redundancy, and these items are the exception: they are not on Re-cut as the promised split-b BEFORE closing 5999; none of it exists anywhere else. (1) src/kiro_crew/dashboard/handlers/autonudge.py::_serialize -- the denylist egress scrub (_UNSCRUBBED_FIELDS = ADDRESSING_FIELDS, per-field scrub_loop_text, Current stateNeither side has merged. the issue/PR reference check lists both as open, and origin/main (1a765b8) contains no part of either: From a repository-wide duplicate/overlap audit of every pull request open against |
Optional per-loop
banneron auto-nudgeProblem / Motivation
A nudge loop's
messageserves two consumers with opposite needs, and today theyget the same string.
_fire_dashboard_nudgecomposes one body and uses it twice:The model needs the whole instruction re-delivered every cycle — that is the
guarantee the nudge exists to provide. A person reading the transcript needs only
"a nudge happened".
Why it matters
Measured on one long-running loop's session file,
dashboard_chat-1896-1787695225.jsonl:Each nudge row was 7,915–7,916 chars — the same payload, 44 times. The largest
single contributor to the file was not the agent's work but the repeated
re-statement of its instructions.
What changed
One optional field. When set, it replaces the message body in the appended row
only. The prompt is untouched.
Threaded through
NudgeLoop,AutoNudgeService.add/update, bothautonudge_authzchokepoints, andPOST/PATCH /api/autonudge. Capped at 500chars (
MAX_BANNER_CHARS), two orders of magnitude under the 8000-charmessagelimit — a generous ceiling here would reintroduce the very bloat the field
removes, so the cap is part of the feature rather than a safety afterthought.
The
[auto-nudge cycle N]prefix is kept on both branches: it is the counter,and the one part of the row a reader wants.
A banner deliberately skips
compose_nudge_body, which prefixes the session'swork-ledger snapshot. That prefix is right for the model (each cycle starts from
durable state) and self-defeating for a display line.
render_nudge_messagestill applies, so
{{STOP_FILE}}resolves in a banner as it does in a message.PATCHaccepts it too, which is what lets a running loop be quieted withoutre-registering: re-arming resets
cycle_countand the wall-clock budget anchor,so a loop found to be noisy mid-run could not otherwise be fixed without
discarding its accounting.
The MCP arming surface also gains
bannermonitor_startandmonitor_updatedeclare an optionalbanner(
mcp_tools/control.py, bounded invalidation.py, applied indashboard/session_directive_apply.py). Without it the only writer was ahand-crafted REST call, so the field would have shipped with no reachable setter
on the surface that armed the loop this PR's own 51.8% measurement came from.
This is the PR's only LLM-facing surface, and it carries a trust asymmetry worth
naming rather than disclaiming: the agent can author the short row shown in place
of its own recurring instruction. It is acceptable because the same
autonudge_authzchokepoints, SEL audit, andGET /api/autonudgevisibilityapply to it as to the REST path — the banner is bounded and scrubbed by the same
code either way.
The two sibling loader redactions are DEFERRED to their own PR
An earlier revision of this PR also redacted the loader warnings in
cron.pyandapps/builtins/ops_mission_control/backend/store.py. Both have been removed from thisdiff. Design and First Principles independently asked for that split -- they share a fix
SHAPE with the autonudge loader, not this feature's fate, and bundling them made a revert
of the banner also revert fleet-wide hardening.
What that leaves here:
redact_store_valueSTAYS, because it is not a rider. It has sevencall sites in
autonudge.pyand is load-bearing for this feature's own loader and sentinelwarnings. Design's suggestion listed it with the riders; that is measurably wrong at source,
so it is kept rather than deleted. It also moves back INTO
autonudge.py: it was relocatedto
platform/context.pyin an earlier revision precisely becausecron.pyimported it, andwith that importer gone the helper belongs beside its only remaining consumer.
cron.py,store.pyandplatform/context.pyare therefore no longer in the changed-file list.The follow-up PR carries those two loaders together with the five remaining
%rsiblingsnamed below, so that sweep lands as one complete fix instead of three of eight.
_loadhardening, which reaches every loop field and not justbannerLoading is where a hand-edited
autonudge.jsonbecomes objects, so the bannerarms live there — and two of the four change behaviour for stores that carry no
banner at all:
stror over-cap banner to""rather thanrejecting the row, so one bad field cannot cost a whole loop.
redact(repr(...))— thereprescapes a control character so a store-suppliednewline cannot split one warning into two records, and the
redactstrips acredential. An earlier draft of this PR carried a hand-rolled
_scrub_for_loghelper for that job; it was deleted in favour of
redact_store_value, so thereis one definition rather than two. There is no
_scrub_for_login this diff, and atest pins its absence.
_serializeexemptsidand
slot_keybecause the client addresses the row by them, so_loadis thetrust boundary that has to guarantee they are safe to serve. A row whose
addressing field is not a
str, is notisprintable(), or is credential-shapedis skipped with a warning naming the field. The
isprintable()arm is what stopslog injection:
redactonly rewrites credential- and URL-shaped text, so anewline rode through it, and the id reaches ~15 bare
%slog calls where onenewline forges a second record. Refusing at this one boundary was chosen over
escaping at each sink, which are many and each new one would have to remember.
This took four attempts and the history matters, because
_serialize_staterewrites thestore WHOLESALE from memory:
eviction, and resurrection handling. Two reviewers called that machinery an over-build.
_loopsisdeleted by the next
add/update/fire.all new arming until a human repaired the file and restarted. Design flagged that cliff.
for that retirement, and a rollback in
_add_locked. The last one could not becompleted: an aborted close rolls back through
chat_handlers._restore_slot_nudge_loop(exc.loop, …)whereexc.loopisget_by_slot(...), which searches_loopsand is thereforeNonefor a row that neverarmed — so the retirement had already reached disk with no token to undo it. That gap
sits in a caller
autonudge.pydoes not own.Shipped: QUARANTINE THE ROW, arm its siblings. The addressing-guard arm appends the
offending row to
_quarantinedand continues, so_load_refusedis NOT set and everyhealthy loop still arms. The row is re-emitted verbatim under a new persisted
quarantinedkey, so the entry the warning asks an operator to fix survives a wholesalewrite instead of being deleted — which is what "the next write drops the row", the earlier
contract, did. Refusing the WHOLE store was the previous shipped answer and was worse in
the common case: one bad row disarmed every unrelated loop. That whole-store arm remains,
but ONLY for a host whose credential policy will not compose at all, where no row can be
vetted. Recovery is repairing the row:
_loadrevalidatesquarantinedon every load, soa repaired row re-arms without a hand edit and without a restart.
test_a_refused_row_does_not_strand_the_loops_loaded_before_itpins that the siblings DOarm and the declined row is preserved;
test_the_two_skip_arms_diverge_deliberatelypinsthe split that remains — a malformed row is dropped and loading continues past it, an
unusable addressing field is quarantined.
Back-compat for the held-aside rows, the same scrutiny
bannergets below: theylive in a SEPARATE
autonudge.quarantine.jsonsidecar rather than in the store payload,so an untouched store keeps its existing wire shape byte for byte and an older build
reads it unchanged. The sidecar is written ADDITIVELY before the main store lands and
compacted only once it has, so a failed replacement cannot leave a repaired row in
neither file; an unreadable sidecar refuses every write in that process and is moved
aside under a
.corrupt-<ts>name, so recovery is a restart, not a hand repair.The same mechanism covers the case it was built for — a host that cannot compose its
credential policy, so NO row can be vetted. There too an empty
_loopsmeans "could notvet" rather than "empty", and every write raises
AutoNudgeStoreUnvettedso the store isnot overwritten with nothing.
hardening. Base
autonudge.py:579logged"AutoNudge: skipping malformed loop entry: %r"of the entire row —messageand any credential inside it — into the log ring and/api/logs,before any banner existed. It now logs the scrubbed id plus the field names
present. That is a security fix and an operator-visible diagnostics change for
all loops, riding under a display feature.
[null],[42],["oops"]) is skipped rather thanraising
AttributeErrorout of_load, restoring the tolerance the old%rhandler had.
Redaction, corrected from an earlier draft of this section:
messageis scrubbedon the way in too.
authorize_autonudge_writeruns both redactors over it(
autonudge_authz.py:226-227), exactly as it does forbanner, so the gap wasnever input-side. It was the producers that BYPASS that authorizer — a
hand-edited
autonudge.json, an internalsvc.add— whosemessagereached thepersisted, broadcast transcript row unscanned, and only on the dashboard path:
_fire_slack_nudgealready scrubbed its own replay row.That gap is closed in code, not deferred. The no-banner branch now scrubs the
visible ROW at the sink (
gateway.py:5721-5723) and deliberately leavestagged— the PROMPT — untouched, because rewriting the instruction the model receives
would corrupt the one guarantee a nudge exists to provide.
So the row is no longer unconditionally byte-identical, and that is the
intended trade. Redaction is idempotent and the authorizer already scrubbed
anything written through REST or MCP, so on every supported path the sink scrub
is a no-op and the row is unchanged; it alters a row only when that row's
messagecarries a credential-shaped span AND reached the store bypassing theauthorizer. Two independent arms pin both halves:
test_the_no_banner_row_is_redacted_like_the_slack_sibling(secret scrubbed fromthe row, prompt intact, row != prompt) and
test_a_clean_no_banner_row_stays_byte_identical(a clean message still passesthrough unchanged).
Rejected alternatives
/context. That is the invisible channel, butdrain_pending_contextis one-shot and clearing, so it offers no per-cycleguarantee. The nudge's whole value is unconditional delivery every cycle;
moving the payload would need a new per-cycle poster and forfeit exactly the
property the nudge exists to provide.
message. Deletes real instruction to quiet a display,and forfeits guaranteed delivery of whatever was removed.
legitimate case where seeing the full nudge text is what you want.
Hence: opt-in, per loop.
Backwards compatibility is the acceptance bar
No store-version bump.
_loadfilters unknown keys:so persistence is already tolerant in both directions:
autonudge.json(nobanner) on new code → field takes its default.autonudge.json(withbanner) on old code → key filtered, noTypeError. A downgrade degrades to today's verbose display rather thancrashing.
_STORE_VERSIONstays at 1. Bumping it would signal a breaking change that isnot happening. The field is appended last in the dataclass for the same
reason — and because nothing constructs
NudgeLooppositionally, verified bygrep across
src/andtest/.The default path is unchanged for a clean message.
test_no_banner_appends_the_pre_change_stringpins the appended row against aliteral rather than against the prompt, so it would still fail if both sides
changed together. It stays green because the sink scrub is a no-op on a message
with nothing credential-shaped in it — the row differs only in the
credential-bearing case, which
test_the_no_banner_row_is_redacted_like_the_slack_siblingpins.Deliberate decision: the WS broadcast does not carry
banner_observerin_init_autonudgebuilds an explicit dict rather than usingasdict, so a new field is not picked up unless added by hand. It is not added,for three reasons:
There is no banner control in the UI, so the field would be dead payload on
every
added/updated/fired/removed/expiredevent.for — the transcript row — which reaches the browser via the normal chat
stream path.
GET /api/autonudgeand thePOST/PATCHresponses go through_serialize,which starts from
asdictand so gains the key automatically. Any consumerthat wants the value has a read path today.
Adding it later is additive and non-breaking; the reverse is not true. If a UI
control lands, that PR is the right place to widen the broadcast.
The broadcast does, however, gain a scrub.
_observernow renders the loop'smessage through the same
scrub_loop_texthelper the REST serializer uses — onedefinition, two callers — so the
autonudge_statepayload cannot serve acredential-bearing message verbatim to every connected browser. The helper is
type-dispatched, so the nine declared numeric and boolean fields pass through
untouched and clients can still do arithmetic on them.
Corrected from an earlier draft of this section, which is the claim both the design
and first-principles lanes flagged:
_serializeis no longer plainasdict. Itis
asdictplus a DENYLIST scrub — every field is scrubbed throughscrub_loop_textunless it is one of the two addressing fields, and the nestedmonitormapping is routed through the structure-preserving_redact_monitor_valuewalker instead, so its shape survives while every nested string is still redacted.
A denylist was chosen over an allowlist because an allowlist silently misses the
next free-text field added to
NudgeLoop— which is exactly howbannercame toneed a scrub of its own. Non-string values are not skipped either: an
agent-written
message: ["AKIA..."]used to be emitted verbatim. The REST payloadstill grows the one
bannerkey — additive, and no consumer does exhaustive keyvalidation.
svc.add/svc.updategain a defaulted kwarg, so the other callers(
dashboard/server.py'sctx.nudgebridge,session_directive_apply,spec_builder's handoff) are unaffected.Beyond the original design sketch
Three things the sketch did not call for, each found by writing the tests:
arm and update paths, exactly as
messageis. It is caller-supplied,persisted to the loop store, and broadcast to every connected browser as a
transcript row on each fire — the same exposure with a shorter string. Being
display-only is what makes this easy to forget. The update path needs it
independently or it is a trivial bypass of the arm-time guard.
banner is truthy, so the row would render blank — worse than the verbose row
it replaced, because it hides the cycle body and puts nothing in its place.
This is not a duplicated guard: the authorizer normalizes REST/workflow input,
while this covers a loop that reached the store another way (hand-edited
autonudge.json, an internalsvc.add). Caught bytest_whitespace_only_banner_is_treated_as_absent, which failed on the firstrun.
at the top of
authorize_and_add_nudge, so a rejection routes through_denyand lands in the SEL audit like every other refusal on that path.
bannerisalso added to the update path's
invokedauditfieldslist.Scope
Dashboard transcript row only. Channel-bound loops (
slack:/discord:/webex:) deliver the nudge as the turn's own input and have no separate displaysurface to shorten, so a non-blank banner on one is refused with a 400 at both
authz chokepoints via
banner_unsupported_for(pinned byTestChannelBoundLoopsRefuseABanner). The reason is mechanical:_fireroutes achannel key to
_fire_slack_nudge/_fire_discord_nudge/_fire_webex_nudge,none of which reads
loop.banner; both read sites sit inside_fire_dashboard_nudge. Storing a setting the runtime can never honour — andreturning 200 for it — left the caller no way to notice but the row not changing.
A blank banner is still accepted there, because
banner=""is the default everychannel-bound caller already passes.
monitor_startandmonitor_updatedo gain an optionalbanner— see theMCP surface note under "What changed". No change to
/context,drain_pending_context,/note, or any loop'smessagecontent. No UI change —the field is opt-in via the API and MCP.
What applies to EVERY loop, banner or not
The banner field itself is opt-in, but several changes here are not scoped to loops
that use it. Stated plainly because it bears on how this PR can be reverted:
_serializeand theautonudge_statebroadcast run every string field throughscrub_loop_text, so ano-banner loop's
messageandstopped_reasonare no longer byte-identical on thewire. Numeric fields are exempt BY FIELD NAME, so client arithmetic is unchanged.
idorslot_keythat is credential-shaped, non-printable or non-
stris held aside underquarantinedwhileits SIBLINGS arm normally, and the file is left untouched.
_load_refusedis set only by thewhole-store arm, for a host whose credential policy will not compose. This applies to loops
that never set
banner. Recovery is repairing the store andrestarting the process, and while it holds, arming through the API fails too. That is the
deliberate trade: the alternative dropped the row the operator was told to fix, or left healthy
loops armed against a store that refuses every write. A malformed row is the one case still
dropped, with loading continuing past it.
with an audited 503 instead of raising, on the arm and update paths alike,
including for requests that carry no banner.
monitor_*schemas changed shape to carry the optional field, whichevery caller of those tools sees.
This was previously recorded as an open question. It is now DECIDED in the direction
both review lanes asked for: the
cron.pyandops_mission_controlloader fixes havebeen removed from this PR and will ride their own. What remains under the banner is the
autonudge surface itself -- its loader, its serializer, its broadcast and its
authorizers -- which is genuinely entangled with the feature and cannot be split the
same way.
The redacted projection is now visible in the UI
GET /api/autonudgeserves a credential-SCRUBBED projection ofmessage, andAutoNudgePopoverseeds its textarea from it. Two hazards followed, both invisible to theperson typing, and both are now surfaced rather than merely guarded server-side:
message_redacted, the popover renders anotice on the textarea saying credentials are shown masked and that saving an edit stores
the masked text. Previously a user whose goal legitimately contained credential-shaped
text saw
[REDACTED: ...]in their own words with no explanation.text is dropped by the server's echo guard, which answers 200 with
message_ignored: true-- a singular boolean, because
messageis the only field this path can ever drop. Thepopover reads it, says in prose that the goal text was left unchanged while the other
settings were saved, and stays open instead of closing on a save that did not fully happen.
The notice interpolates nothing: rendering the raw wire key gave an ambiguous English noun
phrase and an untranslated Latin token in every other locale.
pressing Save no longer writes: the button becomes "Replace goal with masked text", and only
that second press sends the PATCH. The overwrite is irreversible and the server cannot return
the original, so passive 11px copy was the only guard on a destructive default. A user who
changed only interval or cycles is NOT gated, and
message_redacted_noticenow names thesafe path ("Leave the text unchanged to keep the original").
This is also the answer to First Principles' Subtraction, which asked for
message_redactedand the ignored-field report to be deleted on the stated ground of ZEROconsumers. Wiring the popover gives both fields a real consumer, so that premise no longer
holds. Deleting them instead would not have cured the UX hazard: with
message_redactedgone the popover would still seed from the scrubbed message, and the silent overwrite would
become invisible rather than fixed.
Covered by
website/src/test/AutoNudgeRedactedProjection.test.tsx, including a negativecontrol that fails if the notice renders unconditionally.
Tests
test/test_autonudge_banner.py, 85 tests. Two carry the change:body (the branch that must still route through
compose_nudge_body),whitespace-only input, and the dataclass default.
test_row_shows_the_banner_and_the_prompt_keeps_the_message— asserts onthe argument handed to
_run_chat, not only onslot.append. A test checkingonly the row would pass just as well if the prompt had also been shortened,
which is precisely the defect this must not introduce. Four assertions, because
three can pass while the change is still wrong.
Plus: the ledger snapshot stays in the prompt and leaves the row; the cycle prefix
survives the banner branch;
{{STOP_FILE}}renders; the row shrinks below 100chars while the prompt stays above 6000; both persistence directions including a
downgrade simulation against a pre-field dataclass (with a negative control
proving the key filter, not the dataclass, does the work); the cap at and one over
the boundary on both
POSTandPATCH; non-string rejection;Nonevs""semantics differing correctly between arm ("" ) and update (leave alone); and
redaction on both paths.
14 break-arm controls run, 14 confirmed detectable — one per load-bearing
assertion, including the prompt-shortened defect (fails on
assert loop.message in prompt), an off-by-one cap (>→>=), a droppedtype check, dropped redaction on each path independently, and a dropped
handler pass-through.
Verification
Full suite, same venv, changes stashed and unstashed to isolate the diff as the
only variable:
upstream/main@c7f5ba788: 68,848 passed / 57 failed / 35 errorsSet-diffed by test ID. The single remaining delta is
test_pr_watchers.py::TestRegistrySurface::test_get_log_shape_and_incremental_since,which fails 3 of 5 runs on the pristine baseline in isolation — a pre-existing
flake in the auto_improvement PR-watcher suite, whose "nudge loop" is an unrelated
concept. The other pre-existing failures are
git commitandopenssl genpkeysubprocess errors on this host, identical on both sides.
Two hand-rolled test doubles needed updating rather than working around: the
FakeNudgeSvc.addsignature intest_workflows_nudge_wiring.pyand the_Loopstand-in in
test_unattended_slot_guardrails.py. Both are explicitly testdoubles and production only ever receives a real
NudgeLoop, so a defensivegetattr(loop, "banner", "")in the fire path would have been fitting the codeto the test — and would mask a genuine type error later.
Why no screenshot: this revision DOES change what the popover renders -- that earlier
"no pixels" claim is superseded and wrong. Two notices are added above and below the goal
textarea: an amber line reading "Credentials in this goal are shown masked. Saving an edit
stores the masked text, replacing the original instruction. Leave the text unchanged to keep
the original." when the served projection sets
message_redacted, and an amber line reading"Your goal text was left unchanged; the other settings were saved." after a PATCH returns
message_ignored. A third change is the Save button itself, which becomes "Replace goal withmasked text" for one press when an edit would overwrite a redacted goal. All reuse the existing
text-warning text-[11px] mb-1treatment already used elsewhere in this popover, so no newstyling, spacing rule or layout is introduced -- the delta is two conditional text rows.
No captured image is attached because Chromium cannot launch in this build environment
(
browserType.launch: Target page, context or browser has been closed, reproduced with andwithout
--no-sandbox --disable-dev-shm-usage --disable-gpu), and fabricating or hand-drawinga mock would be worse than saying so. The rendered result is pinned instead by
website/src/test/AutoNudgeRedactedProjection.test.tsx, which asserts both notices bydata-testidand includes a negative control that fails if the redaction notice rendersunconditionally. A maintainer who wants a real capture should ask for one.
The reason for the change is a review finding from two lanes: the GET that populates the
popover returns a SCRUBBED projection of
message, so echoing it back unconditionallyoverwrote the stored message with its own redaction. The server had been detecting that
echo and silently dropping the field; the dirty check fixes the cause, and the remaining
server guard now logs when it fires instead of being silent.