Outbound gateway: replace provider policy matrices with per-recipient traffic control - #216
Closed
DevNexsler wants to merge 69 commits into
Closed
DevNexsler wants to merge 69 commits into
DevNexsler wants to merge 69 commits into
Conversation
Adds an MCP tool that fuzzy-searches across messages, participants, channels, and transcripts in a single call. Uses ILIKE filtering (GIN-index-accelerated) with similarity() ranking. Supports OR/AND modes and validates minimum keyword length.
First-class MCP tool wrapping the migration-026 outbound intent lock SQL functions so agents stop hand-writing lock SQL through psql/asyncpg (measured: ~6% of all agent tokens, 152 friction events/week — see Comm-Data-Store docs/proposals/outbound-lock-tool.md). - One `outbound_lock` tool, op = acquire | complete | release | check. - Writable connection lives in the tool (unrestricted driver); message ids / proxy emails travel as JSON args, never inline SQL -> kills the terminal security-scanner false positives. - Strict JSON in/out. - is_duplicate_send_evidence = (completed_at set AND request_ref set), independent of released_at — encoded in every response so the released-vs-completed misread (2026-07-01 near-miss) is structurally impossible. - Pure evidence-semantics helpers + a driver-injected op runner in outbound_lock.py (unit tested, 12 cases); live acquire->block->complete-> post-block-with-evidence->check->release round-trip verified against the DB. Wraps the existing concurrency-safe functions unchanged; no schema change.
…eview fixes
- Provider used for enabled_operations_by_provider/enabled_intents_by_provider
gating is now "tenantcloud" for the four TenantCloud operations regardless
of the wake's message shape. _provider() previously fell through to the
wake's message_source, so a TenantCloud execute on a non-TenantCloud-shaped
wake (e.g. an email-origin wake) was rejected by the allowlist gate -- the
same wake-shape coupling this feature removes, surviving one layer up.
- canonical_context/canonical_scope no longer bake a Python None into the
literal string "None" for tenantcloud_claim_id when a wake has no claim
linkage; store.py's lock_intent and tenantcloud_shared's idempotency key
read this value directly into persisted, immutable identity strings.
- Restored suggest_targets()-based coverage for the wake self-consistency
checks (source/provider/claim linkage/state/owner/family, entity id
format/overflow/alias-conflict, thread-channel agreement) that used to
raise via the removed execute-blocking gate and are now return {} instead;
the per-operation "required ids for this operation" cases don't translate
since suggest_targets is operation-agnostic.
- Restored the cross-operation target-smuggling test for the three id
fields still illegal on LeadStatusArguments (lead_id is now legitimate
there; thread_id/request_id/property_id/unit_id are not).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents choose the outbound target. TenantCloud operations now carry thread_id/lead_id/property_id+unit_id/request_id as typed arguments, validated for format only. Target derivation is demoted from an enforcement gate to an advisory 'suggest' verb on the same tool. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
suggest_targets() (the advisory-only TenantCloud target hint) was still
gating on the retired enforcement rules: tenantcloud_api ownership,
claimed/running state, and event-source/message-source/channel-type
agreement. In production 0 of 142 claims are tenantcloud_api-owned and
only 22/142 are active, so suggest returned {} for nearly every real
wake. It also only ever read tenantcloud_event_claims.entity_ids, which
0 of 142 real claims populate -- the ids actually live in
entity_scope_key's `prefix:id` shape.
Removes the ownership/state/source/channel gating (suggest is read-only
and advisory, so a legacy-owned or completed claim's hint is exactly as
safe as an active one) while keeping the checks that prevent emitting a
wrong id: claim-identity agreement and family-correct keys for
entity_ids, and prefix/parse validation for entity_scope_key. Adds
entity_scope_key to WakeEventRecord/the wake-event query and a parser
covering the real production prefix distribution (lead, tenantcloud:lead,
tc-lead, tenantcloud-lead, tenantcloud-prospect, tenantcloud:maintenance-
request; property slugs and phone numbers contribute nothing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An execute whose write was rejected before the provider ever processed it (authentication_unavailable) is already classified DEFINITIVE_NON_ACCEPTANCE+retryable=True by _from_execution and, via service.py's existing generic routing, retries cleanly. But _from_reconciliation only ever reached that same provably-safe-to-retry conclusion for the two TenantCloud *status* operations (via definitive_absence_detail); for the two *create* operations (message.send, maintenance.create), definitive_absence_detail is None, so a reconcile-time authentication_unavailable result fell through to the generic ambiguous bucket and could ride the ambiguous-create path toward reconcile/manual_review -- even though a reconciliation read that cannot authenticate proves nothing was written, exactly like the invoke-time case. Adds the same authentication_unavailable check to _from_reconciliation, ahead of and independent from definitive_absence_detail, so it applies uniformly across all four TenantCloud operations. service.py itself did not need to change: _finish_observation already promotes any retryable DEFINITIVE_NON_ACCEPTANCE observation to RETRY_READY regardless of whether it arrived via dispatch or reconcile, and ALLOWED_TRANSITIONS already permits RECONCILING -> RETRY_READY -- retryable already reaches that decision point correctly for every disposition the adapter hands it. The ambiguous-create and non-retryable-rejection cases are pinned unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects found by the live production canary: suggest returned {} for
every real wake (it still gated on the removed ownership rules and read
provider ids from entity_ids, which production never populates -- they
live in entity_scope_key), and a reconcile whose own read could not
authenticate escalated the two create operations toward manual review
instead of retrying a request that provably never dispatched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rolls the TenantCloud target-derivation pattern (Task 1) to the remaining four providers. Each of email.send, quo.sms.send, cliq.channel.post, cliq.chat.post, calendar.create, calendar.update, and calendar.delete now carries a required agent-supplied target field (to_address, to_phone, channel_or_chat_id, calendar_id) instead of having its recipient/channel derived from wake data. _target() reads the value straight off request.arguments and always returns verified=True, matching the TenantCloud precedent. The retired wake-side derivation (proxy/direct/ participant email resolution, phone resolution) moves behind suggest_targets() as an advisory hint rather than being deleted, so an agent that doesn't know an id can still ask what the wake implies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents name the outbound target for every provider. email/quo/cliq/calendar now carry to_address/to_phone/channel_or_chat_id/calendar_id as required typed arguments, validated for format only. Recipient derivation moves behind the advisory suggest verb. Quo's real send destination (recipient_phone) comes from the agent too, closing the shared-line wrong-recipient hazard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CalendarAdapter._parse looked for the event under data.content, but Agent Email's request_status envelope carries it under data.event. The old code silently fell through to regexing the human-formatted status text, whose embedded result is JSON-encoded with literal \n instead of real newlines, so the UID/URL regexes never matched and a successfully created event was reported as calendar_reconciliation_inconclusive. Read the event object directly out of the structured payload (walking nested objects the way CliqAdapter._parse already does) and only fall back to the text/regex path when no structured event is present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CalendarAdapter reported calendar_reconciliation_inconclusive for events the provider had actually created. Agent Email's async queue returns the event as data.event in the structured payload, but _parse looked for data.content and fell back to regexing the status text, whose newlines are JSON-escaped. Read the event fields directly, keeping the text path as a fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
calendar.update and calendar.delete were the last two outbound-gateway operations still deriving their event identity solely from the wake payload, which production data shows is always empty (zero raw_events rows carry calendar_event_url), making both operations unreachable. Split CalendarDescriptionArguments into CalendarCreateArguments (unchanged shape) and CalendarUpdateArguments (calendar_id, event_url, etag, event_uid, description), and extended CalendarDeleteArguments with the same event_url/etag/event_uid fields. event_uid defaults to the CalDAV event_url's basename when omitted. context.py now prefers the agent-supplied event_url/etag/event_uid, falling back to the wake-derived values only when the agent omits them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
calendar.update and calendar.delete took the event identity only from the wake payload, which no production wake carries -- both were unreachable. Take event_url/etag (and an optional event_uid, derived from the URL basename otherwise) from agent arguments, keeping the wake values as a fallback. Completes the agent-supplied-targets migration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delete payload carries {deleted:true,event_url} and no event object, so
the uid-seeking walk missed it and successful deletions settled as unknown.
Accept the deletion confirmation, deriving the uid from the URL basename.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TenantCloud lead status updates latched dead on 2026-08-10. Three leads
(2413486, 2417344, 2417650) stranded, every attempt
tenantcloud_auth_rejected_before_dispatch, and the only thing that cleared it
was restarting the container.
Cause is a lifetime mismatch, not a bug in the facade. The Comm-Data-Store
AuthRefreshBudget is SCAN-LOCAL by design (see that repo's
docs/superpowers/specs/2026-07-21-tenantcloud-inactive-runner-refresh-design.md):
it permits exactly one token refresh, then caches that token for the budget's
lifetime so a short batch does not re-read a frozen token per endpoint.
Correct for a scan; wrong for a daemon.
_build_tenantcloud_adapter() constructed one TenantCloudClient inside
build_runtime() and the adapter reused it for the container's whole life, so
"scan-local" became "forever":
Firefox lapses once -> facade spends its single refresh -> caches that
token -> serves it on every later call, never re-reading localStorage.
Ten minutes later the cached token is dead, the budget is spent, and
every write fails until someone restarts the container.
That shape explains the evidence: 13 successes then permanent failure (a latch,
not degradation), no code or config change at the boundary, RestartCount=0 with
40+ hours uptime, and a live get_token returning ttl -132.
Fix: share `control` and `auth` (stateless with respect to token lifetime) and
build the CLIENT per operation, restoring the lifetime the budget assumes. A
single operation still uses one facade, so the pre-write readback and the write
stay in the same scan and the one-refresh anti-storm reservation still holds
within a dispatch.
TenantCloudAdapter now takes `mutations_factory` only. The instance
constructor is deleted rather than deprecated: accepting a ready-made facade is
exactly how the latch was created, and an API that still permits it relies on
every future caller remembering. The 16 existing call sites pass
`mutations_factory=lambda: double`.
Six tests. The load-bearing one asserts on the real builder and fails for the
right reason -- with the old wiring it reports "got 1 client for 2 operations",
not a TypeError. Verified red/green by toggling only server.py with the adapter
held constant, including a simulated regression where the factory closes over a
shared instance. Three tests guard the design that must NOT be relaxed:
one facade per operation (no double auth per dispatch), doubles supplied
through the factory, and no instance constructor.
No Comm-Data-Store change. AuthRefreshBudget behaves exactly as specified.
663 passed, 9 skipped, 1 xfailed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Dockerfile does `ADD . /app`, and there was no .dockerignore, so whatever sat in the tree a human happened to build from went into the release artifact. The image running in production carries 22 stale .pyc files from a worktree's __pycache__ that the main checkout never had. Nothing was broken by it -- all 51 .py files compare byte-identical against a fresh build from main -- but it meant two images built from the same commit did not compare equal, which is exactly the property you want when checking what is deployed. Ignores bytecode, VCS and tool caches. Leaves .venv alone on purpose: the host checkout has one, it is currently copied in and then reconciled by `uv sync --frozen --no-dev`, and excluding it changes what the builder resolves. That is worth doing, but as its own verified change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rrors
ExecuteRequest.intent_kind is now a plain casefolded string (<=64 chars,
non-empty) instead of the IntentKind enum, so agents can attach any
intent label without pydantic rejecting it outright (the string that
burned wake 25789). IntentKind is kept as the vocabulary for
SLOT_REQUIRED_INTENTS and routing maps. appointment_slot is required
when the intent is in SLOT_REQUIRED_INTENTS, forbidden only when the
intent is a KNOWN IntentKind not in that set, and permitted-but-optional
for unknown/free-form intents. ALLOWED_COMBINATIONS is no longer
enforced by validate_matrix (Task 5 archives the constant itself).
Added ExecuteRequest.override: bool = False.
server.py's ValidationError branch now surfaces the failing field
location and message instead of a generic "invalid outbound action
request", with a hint listing valid ActionRole/Operation values for
those two fields.
Rippled the intent_kind: IntentKind -> str change through
ActionContext (context.py) and every `.value` access on
request.intent_kind / context.intent_kind across context.py, store.py,
evidence.py, and service.py, since plain str has no .value attribute.
Deleted tests describing the now-removed matrix enforcement:
- test_role_operation_intent_matrix_fails_closed (test_models.py)
- test_tenantcloud_role_operation_intent_matrix_fails_closed (test_models.py)
Also dropped the {"intent_kind": "freeform"} case from
test_adapter_arguments_and_enums_reject_unknown_values, since an
unknown intent_kind is no longer a rejection.
OutboundActionRecord.intent_kind (service.py) and store.py's _record() reconstruction both still coerced the persisted intent_kind column through IntentKind(...), so a free-form intent (e.g. "escalation") accepted by ExecuteRequest would raise ValueError the moment it was reloaded from the ledger -- exactly the reconcile/resume crash-recovery path that rebuilds ExecuteRequest off this record on every restart. Changed OutboundActionRecord.intent_kind: IntentKind -> str and store.py's _record() to pass the column through as a plain string, matching ActionContext.intent_kind and ExecuteRequest.intent_kind. Dropped the now-unused IntentKind imports from both modules. No other IntentKind(...) coercions remain in store/worker/state_machine (server.py's two IntentKind(item) calls are deliberately untouched -- they validate operator-supplied OUTBOUND_PROVIDER_INTENTS_JSON config allowlists, not persisted request/record data). Added test_free_form_intent_reconstructs_from_a_persisted_row_without_raising to test_action_store.py as a regression guard.
Move retired policy-layer code (provider operation/intent allowlists, matrix combination validator, and env-var parsers) to archive/provider_policy.py with comprehensive README documenting the observed failure (wake 25789, 2026-08-24), root-cause analysis, and revival requirements. - Created archive/__init__.py and archive/provider_policy.py with moved code - Created archive/README.md with failure narrative and env var documentation - Removed DEFAULT_ENABLED_OPERATIONS_BY_PROVIDER, DEFAULT_ENABLED_INTENTS, DEFAULT_ENABLED_INTENTS_BY_PROVIDER, and parser functions from server.py - Removed ALLOWED_COMBINATIONS from models.py - Updated test imports to reference archive module for archived-behavior tests - All live code (_provider(), _enabled_operations(), FEATURE_POLICY) remains Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Restore ALLOWED_COMBINATIONS and DEFAULT_ENABLED_* constants as byte-for-byte verbatim code with original enum-based definitions and type annotations - Move real imports (ActionRole, IntentKind, Operation) to module level, remove TYPE_CHECKING guard to ensure archive module imports cleanly - Remove unused IntentKind import from server.py (F401) - Delete tautological assertions from test_default_email_routing_matches_* that merely verify constant definitions haven't changed (tautologies propping up live test with archive imports) - Move test_explicit_empty_provider_scope_fails_closed (genuine archived-behavior test) to new tests/unit/outbound_gateway/test_archive_provider_policy.py - Remove archive imports from test_server.py; no test files import archive code except test_archive_provider_policy.py - Fix import sorting in archive/provider_policy.py and test_archive_provider_policy.py - Rewrite archive/README.md closing paragraph: gateway gating now depends on pydantic shape validation (ExecuteRequest model) and kill switch, not policy matrices; fine-grained traffic control in Task 6 All ruff checks pass on touched files. Tests: 457 passed, 5 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d add override remediation path
- CRITICAL: enforce-mode block on a fresh RECEIVED action now runs
prepare_outbound_action_and_acquire_lock() (the same call the normal
preflight READY path uses) before claim()+definitive_fail(), because
claim_outbound_action's live whitelist excludes 'received'
(Comm-Data-Store migrations/068_outbound_gateway_observability.sql:156-159).
Previously this raised an unhandled 55P03 in production instead of
returning a blocked PublicResult. FakeStore.claim() now enforces the same
state whitelist so the fake catches this regression.
- CRITICAL: override=true resend of a traffic-blocked DEFINITIVE_FAILED
action now attempts the same successor-action remediation path operator
remediation uses (create_outbound_remediation_context /
retry_of_action_id / next effect_ordinal), instead of hitting the
terminal early-return and staying permanently stuck. That function's own
precondition (an evidence-resolved outbound_action_resolutions row) can
only be satisfied by an operator today, so a resend without one fails
soft with a clear detail instead of crashing on the DB's unhandled
precondition-violation exception.
- IMPORTANT: resume() (worker.py's dependency_wait/prepared/retry_ready
path -- the highest-staleness-risk case, longest wait) now runs the same
traffic gate as execute(), with override forced False (no ExecuteRequest
is available to resume()).
- MINOR: constructing the service with traffic_mode in {shadow, enforce}
and no traffic_probe now logs a warning -- previously it silently
behaved like 'off'.
Adds OutboundActionRecord.error_category (read from the outbound_actions
row) and ActionStore.remediate_traffic_block().
…inal outbound_action_transition_allowed() (Comm-Data-Store migrations/ 067_outbound_action_gateway.sql:346-389) has no dependency_wait -> definitive_failed edge. An enforce-mode block on a DEPENDENCY_WAIT row (reachable via execute() when a fresh RECEIVED action's prepare() hits a contended intent lock, or via resume() on a worker-picked dependency_wait action) was calling claim()+definitive_fail() unconditionally, which would raise the DB's unhandled 'invalid outbound definitive failure state' in production. _check_traffic() now defers instead of terminalizing when the claimable row is DEPENDENCY_WAIT: same pattern _preflight()'s READY branch already uses for lock contention -- return the blocked PublicResult (PENDING status, reason/detail from the verdict) with the row left in DEPENDENCY_WAIT. A deferred block is still a block; the row stays legally re-drivable and the worker's next resume() re-runs the same gate, so lock contention clearing or an override resend both naturally un-defer it. FakeStore.definitive_fail() now enforces the real transition-table guard (raises when expected_state has no edge to definitive_failed) so this class of regression is caught by the suite going forward. Also corrects the prior report's claim that resolve_outbound_action_from_evidence is operator-only: it IS granted to comm_data_store_runtime (migrations/079_runtime_tenantcloud_privilege_boundary.sql:427,459-475). The real reason override-remediation can't be fully autonomous is that function's own precondition -- state='manual_review' plus authoritative provider non-acceptance evidence -- which a policy-driven traffic block never has, not a privilege wall.
… pollution, shared internal key, terminal lease block, silent override, post-dispatch crash escape
CRITICAL 1 -- staleness self-block: newest_activity_after's ledger query
had no action_id exclusion, and execute() persists the action's own
durable row (created_at=now()) *before* the traffic gate runs, so every
real send found its own just-created row as "newer outbound activity"
and stale-blocked itself (shadow mode = 100% false positives). Threaded
exclude_action_id through TrafficProbe, check_traffic, the repository
implementation, and every fake/test double; the ledger query now
excludes the calling action_id, same as in_flight_actions already did.
CRITICAL 1b -- never-dispatched rows pollute staleness: the ledger query
excluded only state='rejected', so a traffic-blocked definitive_failed
row (or any other never-dispatched terminal) counted as "outbound
activity" and could cascade false stale_context blocks onto later sends
to the same recipient. Now only rows that plausibly reached a provider
count: WHERE (dispatch_started_at IS NOT NULL OR state = 'completed').
dispatch_started_at is set exactly at the prepared->dispatching
transition (Comm-Data-Store migrations/067_outbound_action_gateway.sql:774-775).
CRITICAL 2 -- shared internal recipient key: every internal_notification
without a TenantCloud claim used the single literal prospect_id
"internal:none" -- one lease and one staleness watermark for every
escalation across every wake, so concurrent escalations to different
Cliq channels cross-blocked each other. context.py now keys on the
resolved Cliq channel/chat id (f"internal:{target.target_id}") for that
case, falling back to arguments.channel_or_chat_id.
IMPORTANT 6 -- terminal lease block: a deterministic action_id plus a
terminal DEFINITIVE_FAILED meant a seconds-long lease overlap could brick
a resend forever (override never bypasses a lease, only staleness).
enforce+lease_held now defers exactly like the existing DEPENDENCY_WAIT
path -- no claim()/definitive_fail(), the row stays non-terminal and
worker-visible, and the worker's next resume() re-runs the gate and
self-heals once the lease clears. stale_context blocks remain terminal
(a conscious agent decision is still required there).
IMPORTANT 4 -- silent override: override used to short-circuit before
ever fetching newer activity, so an overridden staleness block left
nothing auditable. It now still fetches newest_activity_after (log-only,
no ledger schema change) and logs a WARNING naming what got overridden
when the lease is clear but newer activity exists.
IMPORTANT (post-dispatch crash escape) -- a post-accept provider
exception (e.g. a network timeout after the provider already accepted
the HTTP request) had no try/except on the interactive execute()/
resume() path, so it propagated as a raised error. FastMCP wraps that as
"Error executing tool outbound_action: ...", and the CDS reconciler's
rejection-prefix rule would then uncount a REAL send, letting the wake
complete while the message was actually delivered. execute() and
resume() now share a _dispatch_stage() helper that wraps everything from
_dispatch()/_resume_dependency()/_preflight() onward (NOT context load/
validation, which must still raise) in try/except, logs ERROR, and
returns the row's already-durable state (DISPATCHING/RECONCILING with a
lease) for the existing lease-expiry/reconcile/worker machinery to
recover -- restoring the invariant that an MCP error wrapper strictly
implies "rejected before any provider interaction".
MINORS: (a) archive/README.md no longer claims pydantic enforces a
role+operation+intent match (it only validates operation<->arguments
shape) and no longer calls traffic control future work. (b) NULL
messages.direction now reports "unknown" instead of falsely defaulting
to "inbound". (c) VALID_TRAFFIC_MODES now has one definition
(traffic_control.py), imported by both service.py and server.py instead
of each keeping its own copy.
Author
|
Opened against wrong repo (fork parent) by mistake — please disregard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
src/postgres_mcp/outbound_gateway/archive/with a postmortem of the wake-25789 failure (Melody Haddix, silent quarantine, Maint-Manager tickets 1691/1693).OUTBOUND_TRAFFIC_CONTROLenv:off/shadow(default; logs would-blocks, sends anyway) /enforce. Fail-open on gate malfunction; kill switch remains the only fail-closed control.override=truebypasses staleness only (never lease/kill switch), audit-logged with the overridden message id. Free-formintent_kindmetadata (no more enum rejection ofescalation). Field-level validation errors. Lease and dependency-wait blocks defer non-terminally (self-healing via worker resume); stale-context blocks terminalize with an informativedetailthe calling agent can act on.execute()/resume()— restores the invariant the Comm-Data-Store reconciler's rejection-prefix rule depends on (companion PR in Comm-Data-Store).Spec:
~/.hermes/docs/superpowers/specs/2026-08-27-outbound-gateway-traffic-control-design.mdDeploy notes
OUTBOUND_TRAFFIC_CONTROL=shadow, watchtraffic control shadow would-blockwarnings 3-5 days, thenenforce. Matrix env vars (OUTBOUND_PROVIDER_OPERATIONS_JSON,OUTBOUND_PROVIDER_INTENTS_JSON,OUTBOUND_ENABLED_INTENTS_JSON) are ignored by this code; delete at cutover (last prod values preserved inarchive/README.md).Known follow-ups (before enforce cutover)
_adapter()lookup above_dispatch_stage's try/except (or assert adapter coverage atbuild_runtime): a no-adapter config error is currently log-only instead of raising. Fails safe (wake held open + ERROR auto-ticketed) but violates the pre/post-dispatch boundary.overrideafter an enforce-block requires a CDS migration: successor-action creation is gated on an operator resolution precondition (resolve_outbound_action_from_evidencerequiresstate='manual_review'). Today override works when an operator resolution exists; otherwise it fails soft with clear detail.Testing
🤖 Generated with Claude Code