Skip to content

state(quiet): make quiet mode attributable, bounded and explainable #209

Description

@adrianwedd

The state

spark_quiet_mode is the dysregulation protocol — the Three S's, and the one
policy rule in pxh.policy that binds both origins (policy.py:117). It is
also a naked boolean with an indefinite lifetime, three writers, asymmetric
transitions and no record of why it is true.

#206 did not create this. #206 made enforcement finally match the declared
state; the state itself is now the weak link. This is the correct direction of
failure — each fix exposes the next hidden assumption instead of pushing the bug
sideways.

Evidence (live robot, 2026-08-18)

spark_quiet_mode: true right now. Its origin is not merely unrecorded, it is
unrecoverable:

spark_quiet_mode  : True
history entries   : 100 (limit 100)
window covered    : 2026-08-17T23:05:20Z -> 2026-08-18T06:34:54Z
event kinds seen  : ['emote','mind','perform','remember','status','voice','wake_transcript','weather']
quiet_start/quiet_end/transition_buffer : 0

update_session(history_limit=100) (state.py:259) is a ring buffer sized in
entries, not time. On this robot 100 entries is 7.5 hours. Any latch that
outlives a busy morning becomes unattributable by construction — before you even
ask whether its writer logged anything.

Attributing the current true to Obi, to a transition, or to a dashboard tap
would be fabrication. It should not be attributed at all.

Three writers, two provenance policies, one-way transitions

Writer Sets Clears Logs a history entry
bin/tool-quiet start ✅ true quiet_start
bin/tool-quiet end ✅ false quiet_end
bin/tool-transition buffer ✅ true never transition_buffer
PATCH /api/v1/session (dashboard toggle) ✅ either ✅ either none

patch_session() (api.py:1799) passes fields to update_session() and omits
history_entry entirely, so the dashboard's toggleQuiet() (api.py:2835)
mutates the safety-critical flag and leaves no trace at all. history_entry
being optional is the mechanism: invisibility is the default for any new writer.

tool-transition buffer is the sharper problem. Its own comment calls it a
"20-minute low-demand buffer", but the flag it sets is unbounded and the tool
has no counterpart that clears it. The only exit is tool_quiet end, a
dispatched action — and mind.expression()'s action vocabulary has no quiet
verb, so the autonomous loop can never propose it. A transition buffer creates
a permanent mute that only a human can lift
, and nothing in the state says so.

Proposed shape

Stop treating quiet mode as a naked boolean. Store a record; derive the boolean.

{
  "enabled": true,
  "set_at": "2026-08-18T...",
  "set_by": "dashboard|tool-quiet|tool-transition|...",
  "reason": "manual|dysregulation|transition|...",
  "expires_at": null
}

Deterministic code derives spark_quiet_mode == true for compatibility — every
existing reader (policy.py:117, policy_context, bin/tool-voice,
voice_loop, mind) keeps working unchanged.

That buys, at once:

  • every transition is attributable;
  • dashboard writes stop being invisible;
  • temporary quiet can actually be temporary;
  • deliberate indefinite quiet remains possible;
  • tool-transition cannot silently create a permanent mute;
  • the admin UI can explain why SPARK is quiet instead of showing a bare toggle;
  • future policy can distinguish "manual mute" from "dysregulation protocol"
    without guessing — the thing policy.evaluate() currently cannot see.

Acceptance conditions

  • All writers use one transition function. No caller sets the field directly.
  • Every transition records previous state, new state, source, timestamp and reason.
  • Temporary sources carry explicit expiry/clear semantics; tool-transition buffer
    becomes bounded (or is made to state that it is not).
  • Deliberate manual indefinite mute remains available and is not expired by a sweeper.
  • Dashboard (PATCH /api/v1/session) and tools converge on the same writer —
    the API path must stop being the one that logs nothing.
  • The derived boolean keeps policy.evaluate()'s contract exactly:
    session.get("spark_quiet_mode") is True must keep meaning what it means today,
    and SessionRead.available == False must keep failing closed (fix(policy): enforce #174 at the audio sink, then correct the brain prompt #206, fix(state): a self-healed session is not an authoritative read #208).
  • Old boolean state migrates conservatively without fabricating provenance
    a pre-existing true migrates to enabled with source: unknown, never to a
    guessed origin, and never silently to false.
  • History retention for quiet transitions survives the 100-entry ring buffer
    (the record lives in the state object, not only in history).

Explicitly out of scope

Do not change the current live value. There is no evidence the flag is stale —
only evidence that its origin is unknowable. Preserve it; the migration exists to
make the next one explainable, not to relitigate this one.

Relation to the #174 chain

Ordering matters: #207 widens the set of things quiet mode governs, so quiet mode
should be explainable before it governs more.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions