feat(push): backend chat-fold push trigger (content-free spn)
When a folded async-dispatch synthesis sol_message (B1's `origin`) is broadcast
on callosum, emit a content-free spn push via the dedup path so the answer finds
an owner who has left chat. Backend trigger only, a sibling to the two existing
push triggers. No iOS routing/badge (I3b), no on-device delivery (founder's spn
gate), no chat-engine change (B1 shipped the fold).
handle_chat_fold (triggers.py) fires only on event=="sol_message" with a
non-empty `origin` dict and falsy `requested_target` (the fold discriminator);
no-ops on dispatch acks, direct answers, and non-sol_message events. It
dispatches via dispatch_dedup_via_portal (id+action only) and never via
dispatch_via_portal (the content/summary leak path). Registered as the third
handler in runtime._on_callosum_message; the three handlers are mutually
exclusive on event.
Design decisions:
- Dedup/routing id = origin["logical_use_id"], NOT the fold's top-level use_id.
This corrects the scope's assumption that they are equal: the synthesis turn
carrying a folded answer gets a freshly-minted top-level use_id, while
origin["logical_use_id"] (the original dispatch turn) is stable across both
the live fold and a recovery-rebuilt fold of the same answer. Relay-side dedup
only works on the stable key, so the wire/dedup request_id is
origin["logical_use_id"]. This is the I3a->I3b contract.
- Fold push action = "chat_answer_ready" (module-level FOLD_PUSH_ACTION in
triggers.py, deliberately kept out of sol_initiated/copy.py's locked-literal
and JS-mirror machinery). This is the wire `action` value I3b/iOS keys on.
- Staleness-bounded viewing suppression: an unmatched owner_chat_open within
_VIEWING_STALENESS_MS (15 min) of the fold suppresses the push
(owner_viewing_chat); older unmatched opens are treated as not-viewing so a
lost owner_chat_dismissed (force-quit/crash/dropped SSE) never suppresses
forever. 15 min covers a continuous reading session that emits no fresh open
while self-healing quickly after a lost dismissed.
- Suppression is owner-global for v1 (presence reduces from the single journal's
chat stream; load_devices is owner-global) and reads only the fold's day (the
15-min window is short relative to a day; cross-midnight is an accepted v1
edge).
Nudge log: kind="chat_fold_push", dedupe_key=<logical_use_id>,
category=FOLD_PUSH_ACTION, seconds ts; skip reasons owner_viewing_chat,
no_relay_token, no_devices, portal_unavailable; dispatched/via="portal" on
success.
Tests: 10-case fold matrix in test_push_triggers.py (fires-when-not-viewing,
content-free recovery shape, ack/direct/non-sol_message no-ops, viewing
suppression, stale-open dispatch, dismissed-clears-suppression, origin-id dedup
invariant, gate skips); test_push_runtime.py updated to assert all three
handlers fire in order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>