mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 20:10:34 +00:00
* feat(authz): construction-enforced async decision batches (ActivationDecisions) The follow-up promised in the #4541 review threads. Four review rounds traced four instances of one failure family: an async execution path whose decision-map miss silently fell back to the synchronous authorize() from a worker thread — the wrong API for loop-affine providers, and under fail_closed:false a provider error there flips a denial into an allow. The carrier was a plain dict|None held together by caller discipline. ActivationDecisions (deerflow/authz/activation_decisions.py) makes the contract construction-enforced: - a covered name returns the batched decision (unchanged semantics); - a MISS resolves per the configured provider-error policy (fail-closed denies, fail-open allows) with a loud WARNING — a miss is a prepass/consumer divergence, a construction bug, not a policy question — and never touches a provider; - None remains the sync-chain signal (synchronous authorize() is correct there); - an empty candidate set still yields an empty BATCH, not None, so an unexpected consumer name fails per policy instead of silently taking the sync path; - get_or_none lets publishers distinguish 'uncovered' from False; - fail_closed travels with the batch from the resolved authorization context. Both middlewares (_activation_allowed in SkillActivationMiddleware and SkillToolPolicyMiddleware) migrate to the type; their collectors return batches; the render-decision publisher adapts. Tests 65->70+5: type unit tests (covered/miss both directions with loud-log assertions/ get_or_none/container), middleware-level miss semantics (fail-open allows with provider.sync_calls == [], production-default fail-closed denies, None sync chain still synchronous), and mutation M18 (restoring the miss's sync fallback) verified red. 252 tests green on the affected surface. * docs(authz): sync every decision-batch contract description to the new miss semantics Review round 1 on #6508 — all documentation-level: the docstrings still described the miss-as-synchronous-fallback contract this PR removes. - _activation_allowed (both middlewares): a covered name returns the batched decision; a miss resolves per the carried provider-error policy with a WARNING and never touches a provider; None (sync chain / disabled) is the only synchronous case. - _collect_activation_decisions / _resolve_policy_registry / _active_skills_for_paths: the reason not to diverge is now a policy-resolved miss (denied under the production fail-closed default; allowed without any provider consult under fail-open; logged loudly), not a silent sync fallback. - The two _REGISTRY_LOAD_FAILED sentinel comments, same rewording. - Drop unused tmp_path/monkeypatch fixtures from the two new miss tests. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
88 lines
3.6 KiB
Python
88 lines
3.6 KiB
Python
"""Typed carrier for per-step ``skill:activate`` decisions (async hooks).
|
|
|
|
The async middleware hooks precompute ``skill:activate`` decisions on the
|
|
event loop with ``aauthorize()`` and hand them to the blocking handlers that
|
|
run in worker threads. Before this type existed the carrier was a plain
|
|
``dict | None``: ``None`` meant "sync chain — the synchronous ``authorize()``
|
|
is the correct API", and a name *missing* from a non-None map silently fell
|
|
back to the same synchronous call from the thread — the wrong API for
|
|
loop-affine providers, and under ``fail_closed: false`` a provider error
|
|
there flips a denial into an allow.
|
|
|
|
Four review rounds on #4541 traced four instances of that miss (wrong keys,
|
|
failed prepass, snapshot divergence, a consumer the prepass did not cover).
|
|
This type makes the contract construction-enforced instead of
|
|
caller-disciplined:
|
|
|
|
- ``None`` (absent) — sync chain; consumers call the synchronous
|
|
``skill_activation_allowed`` as before.
|
|
- :class:`ActivationDecisions` (present) — an async batch. A name it covers
|
|
returns the batched decision; a name it does NOT cover fails per the
|
|
configured provider-error policy with a loud log and **never** calls a
|
|
provider synchronously.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ActivationDecisions:
|
|
"""A per-model-step batch of ``skill:activate`` decisions.
|
|
|
|
``fail_closed`` carries the provider-error policy from the resolved
|
|
authorization context so a miss resolves exactly like a provider error
|
|
would (deny when fail-closed, allow when fail-open) — the difference
|
|
being that a miss is always a wiring bug, so it is logged loudly at
|
|
WARNING instead of silently shaping an allow/deny.
|
|
"""
|
|
|
|
__slots__ = ("_decisions", "_fail_closed")
|
|
|
|
def __init__(self, decisions: Mapping[str, bool], *, fail_closed: bool) -> None:
|
|
self._decisions = dict(decisions)
|
|
self._fail_closed = bool(fail_closed)
|
|
|
|
@property
|
|
def fail_closed(self) -> bool:
|
|
return self._fail_closed
|
|
|
|
def __contains__(self, name: str) -> bool:
|
|
return name in self._decisions
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._decisions)
|
|
|
|
def decision_for(self, name: str) -> bool:
|
|
"""The batched decision for *name*; a miss fails per policy, loudly.
|
|
|
|
A miss means the prepass failed to cover a name its consumer
|
|
resolved — a construction bug, not a policy question. It resolves
|
|
like a provider error (fail-closed denies, fail-open allows) and
|
|
logs at WARNING so the divergence is visible in production logs
|
|
instead of silently exercising the wrong API.
|
|
"""
|
|
try:
|
|
return self._decisions[name]
|
|
except KeyError:
|
|
logger.warning(
|
|
"skill:activate decision for '%s' missing from the async batch; failing %s (prepass/consumer divergence — not a policy denial)",
|
|
name,
|
|
"closed (denied)" if self._fail_closed else "open (allowed)",
|
|
)
|
|
return not self._fail_closed
|
|
|
|
def get_or_none(self, name: str) -> bool | None:
|
|
"""The batched decision, or ``None`` when *name* is not covered.
|
|
|
|
For publishers that must distinguish "covered" from "miss" (e.g. the
|
|
rendered-reminder publication treats an uncovered path as hide).
|
|
"""
|
|
return self._decisions.get(name)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return f"ActivationDecisions({self._decisions!r}, fail_closed={self._fail_closed!r})"
|