feat(push): backend chat-fold push trigger (content-free spn) master
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>