[F048] notify: reject human-only recipients (prompter/secretary) — no agent ack path

notify() only checked the SENDER role. The recipient was resolved by
NotificationService._resolve_recipients, which drops only unresolvable slugs
— it does not exclude human-only roles. The prompter (intake-1) and secretary
(secretary-1) are seeded agent rows, so they resolved, and an ack-required
ALERT addressed to them sat permanently unacked (no agent auto-acks it),
polluted the panel's pending-ack view, and — via the dedup query's
~acked_by.contains — permanently suppressed any later same-purpose
notification from the same sender to that human role. The knowledge-share
path already excludes all three human-only roles; the general notify path
did not.

Fix: a recipient-role guard in notify() via _reject_disallowed_recipient
(folds the new check into the existing CEO-dependency-block return slot so
notify stays under the PLR0911 return limit). Rejects prompter/secretary
with not_authorized; the CEO is human too but acks via the panel, so it stays
an allowed recipient (its only disallowed case, a dependency-block page, is
preserved). TDD test_notify.py (+3: reject prompter, reject secretary, allow
CEO).
This commit is contained in:
Renn F
2026-06-28 15:38:56 +02:00
parent ee5321fde1
commit 67b8893071
2 changed files with 128 additions and 1 deletions
+85
View File
@@ -232,6 +232,91 @@ async def test_notify_auditor_rejected_with_not_authorized() -> None:
notif_svc.send_ack_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_notify_rejects_prompter_recipient() -> None:
"""F048: the prompter (intake-1) is a human-only role with no agent ack
path. An ack-required ALERT sent to it sits permanently unacked and — via
the dedup query's ``~acked_by.contains`` — permanently suppresses any
later same-purpose notification to that role. The notify verb must reject
a prompter recipient at the handler, not deliver an un-ackable signal."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
notif_svc = AsyncMock()
deps = _make_deps(task=task_svc, notifications=notif_svc)
ca = ContentActions(deps)
env = await ca.notify(
agent_id=agent_id,
target="intake-1",
text="Please ack this formal signal before proceeding.",
)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert (
"prompter" in body["message"].lower() or "human-only" in body["message"].lower()
)
notif_svc.send_ack_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_notify_rejects_secretary_recipient() -> None:
"""F048: the secretary (secretary-1) is human-only with no agent ack path —
same un-ackable-signal + dedup-suppression hazard as the prompter."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.agent_for.return_value = MagicMock(role="main_pm")
notif_svc = AsyncMock()
deps = _make_deps(task=task_svc, notifications=notif_svc)
ca = ContentActions(deps)
env = await ca.notify(
agent_id=agent_id,
target="secretary-1",
text="Please ack this formal signal before proceeding.",
)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert (
"secretary" in body["message"].lower()
or "human-only" in body["message"].lower()
)
notif_svc.send_ack_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_notify_allows_ceo_recipient() -> None:
"""F048: the CEO is human-only too, but the human acks via the panel, so a
non-dependency-block CEO notification is a valid ack-required target. The
recipient guard must NOT over-exclude the CEO (only prompter/secretary)."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.get_journal_context_task_for_agent.return_value = None
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
notif_svc = AsyncMock()
deps = _make_deps(task=task_svc, notifications=notif_svc)
ca = ContentActions(deps)
env = await ca.notify(
agent_id=agent_id,
target="ceo",
text="Heads up: this major task is ready for your final approval.",
)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "sent"
notif_svc.send_ack_notification.assert_awaited_once()
@pytest.mark.asyncio
async def test_notify_auto_fills_task_id_from_active_task() -> None:
"""When the PM has an active task, notify auto-attaches it."""