Skip to content

feat(outreach): interest-triggered outreach — salience event, quality gate, feedback loop (PN-94, Phase 1) - #97

Merged
dnacenta merged 3 commits into
mainfrom
feat/PN-94-interest-triggered-outreach
Aug 19, 2026
Merged

feat(outreach): interest-triggered outreach — salience event, quality gate, feedback loop (PN-94, Phase 1)#97
dnacenta merged 3 commits into
mainfrom
feat/PN-94-interest-triggered-outreach

Conversation

@dnacenta

Copy link
Copy Markdown
Owner

Closes #94.

Spec: /opt/pulse-vault/pulse-null/specs/in-progress/interest-triggered-outreach-spec.md (v1.0, D + Echo).

Merge order: before PN-95 (#96) per spec §9 — this is the outlet; the tension store is its Phase 2 trigger.

What's in (Phase 1)

  • EntityEvent::Salience + SalienceKind (Finding / Development / Blocking / Callback); [SALIENCE:] marker parsed alongside [INTENT:]/[SCHEDULE:]; listener converts to a Share-routed intent.
  • Quality gate (src/outreach/), cheapest-first (referent → cost → novelty), pass requires all three:
    • Gate 1 novelty via real embeddings (recall_echo LazyEmbedder, BGE-Small, shared model cache); embedder error = rejection, never a pass.
    • Gate 2 external referent, mechanically defined (file:line / prediction id / URL / number+command on the same line). Stricter for Development: journal-internal file paths don't count — self-authored prose wearing a path is still self-authored.
    • Gate 3 stated cost, normalized onto evidence as a Cost: line so the event stays five fields.
  • Feedback loop: {entity}/outreach.json (flock-guarded fail-closed), rolling response rate per kind over 20, cap auto-halves below 0.3 with an announced notice — recorded as announced only after the webhook confirms delivery; fires on rejections too, since a 1→0 halving admits nothing.
  • Per-kind daily caps; quiet hours 23–08 local; Blocking uncapped and quiet-hours-exempt, also exempt from the listener's generic cooldown/circuit-breaker (which would silently throttle it). allow_call_routing = false, no automatic Call.
  • CLI: pulse-null outreach status + pulse-null outreach respond <id> [--rating useful|noise] (the response signal needed an inbound path; Discord reply detection remains unwired).

Verification

fmt clean; clippy --all-targets --features discord-text -- -D warnings zero warnings; cargo test --features discord-text 910 passed / 0 failed (~120 new); release build OK; outreach status smoke-tested on a scratch entity. Pre-existing unrelated flake in claude_code_provider::tests (ExecutableFileBusy, ~1 in 3) reproduced on unmodified main.

Known seams (deliberate, documented)

  1. Literal §2.4 halving takes cap_development 1 → 0 (silences the kind until responses restore the window) — no .max(1) floor was added; the notice says how to recover.
  2. Sends counted at admission, not delivery (loud log if a queued intent never runs).
  3. IntentOutput::Share is set per spec but output_routing has no consumer anywhere — delivery actually rides [SHARE:]; the intent instructs the prompt to emit it. Cleanup issue worth filing.
  4. Operationally inert until [autonomy] enabled = true — the listener only spawns with autonomy on (off in prod since 2026-08-16, with 16 stranded intents that will replay when flipped).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EDtGQtXxs4fVK4g484Y1Fj

Every event in the bus was about the health of the machinery — a document
at its limit, a frozen pipeline, a failed provider. None was about the
thinking, so every unprompted message the entity could generate was
structurally a status report. This adds the content event and the controls
that stand between raising one and D's phone buzzing.

EntityEvent::Salience { kind, thread_id, headline, evidence, confidence }
plus SalienceKind (Finding/Development/Blocking/Callback). The listener
turns an admitted candidate into a Share-routed intent; Blocking gets
Urgent priority. Call routing stays manual (allow_call_routing = false).

The quality gate (spec §2.3) is three mechanical checks, none of which is
"the entity judges it worthwhile" — that is the judgement under audit:

  2. External referent. The evidence must cite a file:line, a prediction id
     that actually resolved, a URL, or a number on the same line as the
     command that produced it. A bare digit is rejected: "chewing on this
     for 3 days" would clear a digits-anywhere check and clear it forever.
     Stricter for Development, never softer — a file reference into the
     entity's own journal is self-authored prose wearing a file path, so it
     does not count for that kind (spec §6.1).
  3. Stated cost. The message must say whether it wants nothing, a read, or
     a decision.
  1. Novelty. Cosine similarity of the headline against prior outreach plus
     journal headings, over recall-echo's fastembed path (the same model the
     graph indexes episodes with). Run last because it is the only expensive
     check; the verdict is unchanged, the reported reason more actionable.

Fail-closed throughout: an unavailable embedder, an unreadable store or a
missing cost line all reject. Under-firing is recoverable; over-firing is
not, because a channel D starts skimming past does not come back (§6.4).

Feedback (spec §2.4) is the part that matters. Rolling response rate per
kind over the last 20 messages; below the 0.3 floor the daily cap halves
and D is told. Two properties are deliberate: neglect tightens rather than
loosens (an unread control is not a control), and the tightening is
announced — the notice is emitted on rejections too, because a cap halved
to zero admits nothing and a notice riding on admission would never fire.
Development halving 1 → 0 silences the kind outright; that is the spec
taken literally, survivable only because it is announced.

outreach.json carries the sent log, responses and rejections under the same
flock + atomic-rename + quarantine-on-corrupt discipline as predictions.json
(PN-86). The admission decision and the write that records it happen inside
one locked read-modify-write, so two candidates racing on a cap of one
cannot both observe an empty budget. Sends are counted at admission, which
over-counts rather than under-counts — the recoverable direction.

Quiet hours 23:00–08:00 in D's local time, wrap-aware; Blocking overrides
them and nothing else does. Salience is exempt from the listener's generic
60-minute cooldown and circuit breaker: those would silently throttle the
kind the spec requires to be uncapped, and retire the channel after three
messages. The caps are the control.

`pulse-null outreach status` publishes caps, rates and rejections — a gate
whose rejections nobody can read cannot be audited, and a gate that never
rejects is not a gate. `outreach respond` records D's reaction, without
which the only scoring signal in the system has no way in and every cap
tightens to half and stays there.

Refs PN-94
Wires the outreach channel to something that can actually raise a
candidate. The marker sits alongside [INTENT:] / [SCHEDULE:] / [CHAIN:]
and is routed from both execution paths — a scheduled task and an intent
mean the same thing by it.

Extraction uses the brace-balanced scan rather than a lazy regex, the same
way [FARM:] does. The evidence field carries free-form text that routinely
contains brackets — file paths, citations, quoted markers — and a lazy
regex stopping at the first `}]` would truncate it, silently stripping the
external referent gate 2 exists to check for.

`kind` is required and never defaulted: a typo must not inherit Blocking's
uncapped, quiet-hours-overriding budget or dodge Development's stricter
gate. An optional `cost` field is normalized onto the evidence as a `Cost:`
line, so gate 3 stays a pure function of the event whatever raised it — the
marker today, the tension store in Phase 2. An inline cost line already in
the evidence wins, since that is the one written in context.

Emitting the marker sends nothing. The event goes on the bus and the
listener runs the gate, the quiet window and the daily cap; a malformed
marker is warned about and dropped.

This is knowingly the weak trigger. The entity decides when it is
interesting, which is unaudited — that is the whole of §3 Phase 1. It ships
first because it makes the behaviour observable, and observable behaviour
is what makes the Phase 2 tension-store trigger falsifiable: same gate,
same caps, compare response rates.

The marker is documented to the entity only when [outreach] enabled is
true, so it is never told about a marker whose output would be discarded.

Refs PN-94
…atus

`outreach status` rendered an unfilled response window as "0/1 messages",
because the denominator was the fill count guarded with `.max(1)` rather
than the configured `feedback_window`. That reads as a window one message
from acting, when in fact twenty are needed before the rate moves a cap —
exactly backwards for the number D would use to judge whether the
tightening machinery is close to firing.

KindStatus now carries `window_filled` and `window_target` separately; the
ambiguous `window_size` is gone.

Refs PN-94
@dnacenta
dnacenta merged commit 0392370 into main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interest-triggered outreach — unprompted contact with content behind it (Phase 1)

1 participant