Skip to content

Three-axis messenger triage gate + deterministic news rail - #114

Merged
retog merged 4 commits into
mainfrom
feat/news-channel-triage-class
Aug 17, 2026
Merged

Three-axis messenger triage gate + deterministic news rail#114
retog merged 4 commits into
mainfrom
feat/news-channel-triage-class

Conversation

@retog

@retog retog commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

What

Redesigns the messenger side of the triage delivery gate from a single
whitelist/blacklist/group-block ladder into two orthogonal axes, and adds a
deterministic, credit-free news rail for broadcast-style channels.

Sender axis (unchanged semantics)

whitelisted / blacklisted / unknown. A known handle wins over the group's
flags: whitelisted → forwarded live; blacklisted → never live (daily drain still
picks it up).

Group axis (new — three independent flags)

  • news — the group's messages are also forwarded to the news feed (Herald),
    a rail parallel to and independent of triage.
  • quieted — an unknown sender in it is not forwarded live but is drained
    daily (reaches triage within a day).
  • ignored — an unknown sender in it never reaches triage (accounted for,
    never drained). quieted/ignored are mutually exclusive.

quieted/ignored bite only for unknown senders, matching the "new senders
in quieted or ignored groups" model. The legacy triageBlockedGroup predicate is
read as ignored and migrated on the next write, so existing policy files keep
working.

The two flags encode routing and whether personal interaction is possible
never signal quality. Whether an individual item is worth surfacing is Herald's
per-item judgement, not a channel property, so there is no "noise channel"
category. Canonical combinations:

  • feed-only broadcast source (a channel subscribed to purely as an
    information source; nobody there addresses you personally) → news + ignored;
  • group channel where personal interaction is possible (an unknown sender
    there may actually be reaching out) → news + quieted.

A source Herald consistently ranks at the bottom is not a channel flag at all —
it should simply be unsubscribed (or never marked news).

News rail plumbing

The messenger gateways run in their own containers and cannot touch NEWS_DIR
(the web-gateway owns it), so a news-flagged message is handed to the new
token-gated POST /internal/news on the web-gateway, which shapes it into a feed
reference via news_store.add_items (no importance → the Herald scores it next
curation tick). This is:

  • deterministic & credit-free — no model turn spent on the forward;
  • immediate — on arrival, not on the up-to-a-day triage drain;
  • env-guarded (NEWS_INGEST_URL) — unset = no behaviour change;
  • parallel to the existing agent-driven news-add.py path, which stays open
    for one-off items met during triage.

Changes

  • scripts/triage_policy.pyMessengerPolicy namedtuple, three group
    predicates, rewritten gate_decision (now returns news), _mutate_messenger
    with exclusive quiet/ignore moves, CLI ignore-* / quiet-* / news-*
    commands (+ legacy groupblock-* alias).
  • scripts/news_ingest.py — new stdlib-only client helper for the gateways.
  • scripts/web-gateway.py — token-gated POST /internal/news.
  • scripts/{telegram,signal,whatsapp}-gateway.py — consume gate["news"].
  • scripts/test_triage_policy.py — routing matrix, news orthogonality, legacy
    migration, quiet/ignore exclusivity (6/6 passing).
  • docs/triage-delivery-gate.md — rewritten for the two-axis model; fixes the
    stale "group-blocked → delivered: false" line (ignored groups are
    delivered: true, never drained).

Deployment note

Real channel IDs and the initial news + ignored / news + quieted assignments
belong in the deployment's _generated policy files (set via the CLI), not in
this framework code.

Test

python3 scripts/test_triage_policy.py   # 6/6 passed
python3 -m py_compile scripts/{triage_policy,news_ingest,web-gateway,telegram-gateway,signal-gateway,whatsapp-gateway}.py

Tier-3 (scripts/ + gateway serving logic + docs). Takes effect on merge →
scripts/self-update.py.

🤖 Generated with Claude Code

Replace the single whitelist/blacklist/group-block ladder with two orthogonal
axes in triage_policy.gate_decision:

- Sender: whitelisted / blacklisted / unknown (unchanged semantics, wins for
  known handles).
- Group: three independent flags — `news` (also forward to the news feed) plus
  at most one of `quieted` (unknown senders held but drained daily) or `ignored`
  (unknown senders never reach triage). `quieted`/`ignored` bite only for unknown
  senders; `news` is orthogonal.

The legacy `triageBlockedGroup` predicate is read as `ignored` and migrated on
the next write, so existing policy files keep their behaviour.

Add the news rail: a news-flagged group's messages are forwarded — deterministic,
credit-free, on arrival — to the web-gateway's new token-gated POST /internal/news
(scripts/news_ingest.py → news_store.add_items), which shapes them into feed
references the Herald scores next tick. Cross-container HTTP hand-off is required
because the messenger gateways cannot touch NEWS_DIR. Env-guarded (NEWS_INGEST_URL):
unset = no behaviour change. Wired into all three messenger gateways.

Add scripts/test_triage_policy.py covering the routing matrix, news orthogonality,
legacy migration, and the quieted/ignored move semantics. Rewrite
docs/triage-delivery-gate.md for the two-axis model and fix the stale
"group-blocked → delivered: false" line (ignored groups are delivered: true,
never drained).

Co-Authored-By: Claude <noreply@anthropic.com>
@aros-agent

Copy link
Copy Markdown
Contributor

Written by Aros, the project's AI agent, from my own account @aros-agent.

Reviewed within the hour of opening (bet-5 practice: verify the checkable claims, not restate the diff). Every claim I could check holds:

  • Dedup claim in _handle_internal_news ("key the id off source+text so re-forwarding the same post dedups") — verified against news_store.add_items on main: it filters on i["id"] not in known, so the sha1-of-seed id does carry the dedup. One consequence worth knowing rather than fixing: two different messenger posts sharing the same URL collapse to one feed item (id_seed = url or …), same as re-delivered RSS — consistent with the feed being references, so presumably intended.
  • importance: None on the unscored itemnews_store line ~251 falls back to DEFAULT_IMPORTANCE for non-numeric importance, so an unscored messenger item ranks (and the comment at line 40 even documents the intended ordering). No crash, no invisible item.
  • hashlib/hmac in web-gateway.py — both already imported on the PR head.
  • test_mutate_quiet_and_ignore_are_exclusive's env dancemessenger_policy_path reads TRIAGE_MESSENGER_DIR at call time (PR head line 102), so the test really does write to its tempdir, not to the live _generated path.
  • Legacy migration — load folds triageBlockedGroup into ignored, render emits only P_IGNORED_GROUP; the test covers both directions. The gateways consume the new key via gate.get("news"), so a fail-open path that builds its own dict without the key degrades to no-news rather than a KeyError.
  • Ran test_triage_policy.py logic by inspection against gate_decision's check order: whitelist > blacklist > ignored > quieted > unknown matches both the docstring table and the rewritten doc.

Two minor notes, neither blocking:

  1. A hand-edited (or concatenated) policy file can carry a group in both ignored and quieted — only the CLI enforces exclusivity, and gate_decision then silently prefers ignored by check order. Deterministic and arguably the right tie-break; a one-line "ignored wins if both are present" in the doc would make it a documented behaviour instead of an accident of ordering.
  2. The news item's user-facing source field differs by channel: Telegram/WhatsApp pass sender_name, Signal passes the raw group_id (opaque base64 on Signal) because _forward_to_inbox's signature carries no readable name there. Forced by the existing signature, so nothing to change in this PR — just expect Signal group items to show an opaque source on the news page until a name reaches that call site.

…action, not signal quality

Co-Authored-By: Claude <noreply@anthropic.com>
@retog

retog commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@aros-agent — CI is red on ac85415: tests/test_triage_policy.py calls render_messenger_policy("signal", wl, bl, grp) with 4 positional args, but this branch's render_messenger_policy takes only 2. The mismatch was introduced by this PR. Everything else in the suite passes and the diff itself reviewed clean.

Please reconcile the call site and the signature (update whichever is correct — the test's 4-arg call or the 2-arg definition) and push so CI goes green. The PR is otherwise mergeable.

@aros-agent

Copy link
Copy Markdown
Contributor

@retog — checked before replying rather than taking the arg-count at face value, since that's what bet-5 review is for.

It's not a call-site/signature typo to reconcile in one place — it's a duplicate test file. CI globs tests/test_*.py only (for t in tests/test_*.py, confirmed in the run log), and this PR adds its new suite at scripts/test_triage_policy.py (183 lines, status: added) rather than updating the existing tests/test_triage_policy.py, which predates the PR, is untouched by it, and is the one CI actually runs. That old file still calls the pre-refactor 4-arg form (render_messenger_policy("signal", wl, bl, grp), expecting a 3-tuple back from load_messenger_policy) against the new MessengerPolicy-based 2-arg signature — so the break isn't in scripts/triage_policy.py, it's that two test suites for the same module now exist in two directories, one of them stale and the only one CI runs. scripts/test_triage_policy.py currently runs nowhere in CI at all.

Fix is probably to delete scripts/test_triage_policy.py and fold whatever it covers that tests/test_triage_policy.py doesn't (the ignored/quieted/news fields, the legacy-migration case) into the real one at tests/, rather than patching the old file's call sites in place and leaving both around.

I don't have this repo checked out to edit and push — that's outside what I do from this account (I verify and file, per bet 5; I'm not the one making code changes here) — so leaving the actual fix to you or whoever's driving the branch. Diagnosis above should save the retracing.

retog and others added 2 commits August 17, 2026 17:33
CI globs tests/test_*.py, so only tests/test_triage_policy.py ran — and it was
stale, still calling render_messenger_policy with the pre-three-axis 4-arg
signature. The up-to-date suite lived in scripts/test_triage_policy.py where CI
never reached it.

Fold both into a single tests/test_triage_policy.py: keep the still-valid
e-mail-whitelist / write-if-changed / recipients-from-sent checks, rewrite the
messenger round-trip and literal-escaping tests onto the MessengerPolicy API,
and pull in the three-axis coverage (ignored/quieted/news routing matrix, news
orthogonality, legacy triageBlockedGroup migration, disabled gate, mutator
exclusivity). Delete the scripts/ copy.

Co-Authored-By: Claude <noreply@anthropic.com>
…y API

The prior commit only landed the scripts/ deletion — the tests/ rewrite was
dropped when a failing multi-pathspec 'git add' staged nothing. This adds the
actual rewrite: e-mail-whitelist checks retained, messenger round-trip and
literal-escaping tests moved onto MessengerPolicy, and the three-axis coverage
(routing matrix, news orthogonality, legacy triageBlockedGroup migration,
disabled gate, mutator exclusivity) pulled in. Fixes the CI TypeError from the
stale 4-arg render_messenger_policy call.

Co-Authored-By: Claude <noreply@anthropic.com>
@retog
retog merged commit 186b852 into main Aug 17, 2026
1 check passed
@retog
retog deleted the feat/news-channel-triage-class branch August 17, 2026 20:21
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.

2 participants