feat: harden shared platform contracts

This commit is contained in:
2026-07-30 14:26:36 +02:00
parent 6970bf7457
commit 9b88ae388b
24 changed files with 2034 additions and 57 deletions
+68
View File
@@ -20,6 +20,7 @@ from govoplan_core.core.events import (
publish_platform_event,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.postbox import (
@@ -51,6 +52,8 @@ celery.conf.update(
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
"govoplan.mail.purge_outbox": {"queue": "mail"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
@@ -69,6 +72,16 @@ celery.conf.update(
"schedule": 60.0,
"args": (None, 100),
},
"mail-outbox-every-five-seconds": {
"task": "govoplan.mail.dispatch_outbox",
"schedule": 5.0,
"args": (None, 25),
},
"mail-outbox-retention-daily": {
"task": "govoplan.mail.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (250,),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
@@ -151,6 +164,16 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability
def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX)
if not isinstance(capability, MailDeliveryOutboxProvider):
raise RuntimeError("Mail delivery outbox capability is invalid")
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
@@ -265,6 +288,51 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
return result
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
def dispatch_mail_outbox(
self,
tenant_id: str | None = None,
limit: int = 25,
):
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {
"selected": 0,
"accepted": 0,
"partially_refused": 0,
"retrying": 0,
"failed": 0,
"outcome_unknown": 0,
"command_ids": [],
}
return dict(
provider.dispatch_due(
session,
tenant_id=tenant_id,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
def purge_mail_outbox(self, limit: int = 250):
"""Minimize expired Mail payloads while retaining delivery evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {"purged": 0}
return dict(provider.purge_expired(session, limit=limit))
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database."""
+559
View File
@@ -0,0 +1,559 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
from sqlalchemy.orm import Session
class ConcurrencyError(RuntimeError):
"""Base class for mutation precondition and compare-and-set failures."""
class MissingPreconditionError(ConcurrencyError):
def __init__(self, *, resource_type: str, resource_id: str) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__("A strong If-Match precondition is required for this mutation.")
def as_dict(self) -> dict[str, Any]:
return {
"code": "precondition_required",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"retryable": True,
}
class RevisionConflictError(ConcurrencyError):
def __init__(
self,
*,
resource_type: str,
resource_id: str,
current_revision: int,
submitted_base_revision: int,
refresh_path: str | None = None,
conflicts: Sequence["MergeConflict"] = (),
merge_candidate: Any = None,
current_etag: str | None = None,
) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
self.current_revision = int(current_revision)
self.submitted_base_revision = int(submitted_base_revision)
self.refresh_path = refresh_path
self.conflicts = tuple(conflicts)
self.merge_candidate = merge_candidate
self.current_etag = current_etag
super().__init__(
f"{resource_type} {resource_id} changed from revision "
f"{submitted_base_revision} to {current_revision}"
)
@property
def safe_merge_available(self) -> bool:
return self.merge_candidate is not None and not self.conflicts
def as_dict(
self,
*,
include_values: bool = False,
include_merge_candidate: bool = False,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": "revision_conflict",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"current_revision": self.current_revision,
"submitted_base_revision": self.submitted_base_revision,
"retryable": True,
"safe_merge_available": self.safe_merge_available,
"conflicts": [
conflict.as_dict(include_values=include_values)
for conflict in self.conflicts[:100]
],
}
if self.refresh_path:
result["refresh_path"] = self.refresh_path
if self.current_etag:
result["current_etag"] = self.current_etag
if include_merge_candidate and self.safe_merge_available:
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
return result
@dataclass(frozen=True, slots=True)
class MergeConflict:
path: str
kind: str
base_value: Any = None
local_value: Any = None
current_value: Any = None
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
"path": self.path or "/",
"kind": self.kind,
}
if include_values:
result.update(
{
"base_value": _bounded_json_value(self.base_value),
"local_value": _bounded_json_value(self.local_value),
"current_value": _bounded_json_value(self.current_value),
}
)
return result
@dataclass(slots=True)
class ThreeWayMergeResult:
value: Any
conflicts: list[MergeConflict] = field(default_factory=list)
applied_paths: list[str] = field(default_factory=list)
@property
def merged(self) -> bool:
return not self.conflicts
ProtectedPath = str | Callable[[str], bool]
def strong_resource_etag(
resource_type: str,
resource_id: str,
revision: int,
) -> str:
"""Return an opaque strong ETag for one mutable aggregate revision."""
normalized_revision = int(revision)
if normalized_revision < 1:
raise ValueError("Resource revisions must be positive integers")
digest = hashlib.sha256(
"\x00".join(
(
"govoplan-strong-revision-v1",
str(resource_type),
str(resource_id),
str(normalized_revision),
)
).encode("utf-8")
).hexdigest()
return f'"sha256-{digest}"'
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
"""Apply strong comparison semantics to an If-Match header."""
if not header_value:
return False
for raw_candidate in header_value.split(","):
candidate = raw_candidate.strip()
if candidate == "*":
return True
if candidate.startswith("W/"):
continue
if candidate == expected_etag:
return True
return False
def assert_revision_precondition(
if_match: str | None,
*,
resource_type: str,
resource_id: str,
submitted_base_revision: int,
) -> None:
if not if_match:
raise MissingPreconditionError(
resource_type=resource_type,
resource_id=resource_id,
)
submitted_etag = strong_resource_etag(
resource_type,
resource_id,
submitted_base_revision,
)
if not if_match_matches(if_match, submitted_etag):
raise ConcurrencyError(
"If-Match does not identify the submitted base revision."
)
def claim_revision(
session: Session,
*,
model: type[Any],
filters: Iterable[Any],
revision_attribute: str,
expected_revision: int,
resource_type: str,
resource_id: str,
refresh_path: str | None = None,
) -> int:
"""Atomically claim the next revision in the caller's transaction."""
revision_column = getattr(model, revision_attribute)
expected = int(expected_revision)
normalized_filters = tuple(filters)
query = session.query(model).filter(*normalized_filters)
updated = query.filter(revision_column == expected).update(
{revision_column: revision_column + 1},
synchronize_session=False,
)
if updated == 1:
session.flush()
return expected + 1
current = (
session.query(revision_column)
.filter(*normalized_filters)
.scalar()
)
if current is None:
raise LookupError(f"{resource_type} {resource_id} was not found")
raise RevisionConflictError(
resource_type=resource_type,
resource_id=resource_id,
current_revision=int(current),
submitted_base_revision=expected,
refresh_path=refresh_path,
current_etag=strong_resource_etag(
resource_type,
resource_id,
int(current),
),
)
def three_way_merge(
base: Any,
local: Any,
current: Any,
*,
protected_paths: Sequence[ProtectedPath] = (),
stable_id_fields: Sequence[str] = ("id",),
) -> ThreeWayMergeResult:
"""Conservatively merge local changes onto a concurrently changed value."""
return _merge_value(
copy.deepcopy(base),
copy.deepcopy(local),
copy.deepcopy(current),
path="",
protected_paths=tuple(protected_paths),
stable_id_fields=tuple(stable_id_fields),
)
def _merge_value(
base: Any,
local: Any,
current: Any,
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
local_changed = local != base
current_changed = current != base
if not local_changed:
return ThreeWayMergeResult(value=current)
if local == current:
return ThreeWayMergeResult(value=current)
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, local, current)
if not current_changed:
return ThreeWayMergeResult(
value=local,
applied_paths=[path or "/"],
)
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
return _merge_mapping(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
return _merge_list(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
return _conflict(path, "same_path_changed", base, local, current)
def _merge_mapping(
base: Mapping[str, Any],
local: Mapping[str, Any],
current: Mapping[str, Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
missing = object()
result: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
for key in keys:
child_path = _join_path(path, str(key))
base_value = base.get(key, missing)
local_value = local.get(key, missing)
current_value = current.get(key, missing)
merged = _merge_presence(
base_value,
local_value,
current_value,
missing=missing,
path=child_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result[str(key)] = merged.value
return ThreeWayMergeResult(
value=result,
conflicts=conflicts,
applied_paths=applied_paths,
)
def _merge_presence(
base: Any,
local: Any,
current: Any,
*,
missing: object,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
if local is missing and current is missing:
return ThreeWayMergeResult(value=missing)
if base is missing:
if local is missing:
return ThreeWayMergeResult(value=current)
if current is missing:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", None, local, None)
return ThreeWayMergeResult(value=local, applied_paths=[path])
if local == current:
return ThreeWayMergeResult(value=current)
return _conflict(path, "concurrent_add", None, local, current)
if local is missing:
if current == base:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, None, current)
return ThreeWayMergeResult(value=missing, applied_paths=[path])
return _conflict(path, "delete_vs_edit", base, None, current)
if current is missing:
if local == base:
return ThreeWayMergeResult(value=missing)
return _conflict(path, "edit_vs_delete", base, local, None)
return _merge_value(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
def _merge_list(
base: list[Any],
local: list[Any],
current: list[Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
identity_field = _stable_identity_field(
(base, local, current),
stable_id_fields,
)
if identity_field is None:
return _conflict(path, "unkeyed_collection", base, local, current)
base_by_id = {str(item[identity_field]): item for item in base}
local_by_id = {str(item[identity_field]): item for item in local}
current_by_id = {str(item[identity_field]): item for item in current}
base_order = list(base_by_id)
local_order = list(local_by_id)
current_order = list(current_by_id)
local_reordered = _common_order(local_order, base_order) != _common_order(
base_order,
local_order,
)
current_reordered = _common_order(current_order, base_order) != _common_order(
base_order,
current_order,
)
if local_reordered and current_reordered and local_order != current_order:
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
result_by_id: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
missing = object()
for identity in identities:
item_path = _join_path(path, f"{identity_field}={identity}")
merged = _merge_presence(
base_by_id.get(identity, missing),
local_by_id.get(identity, missing),
current_by_id.get(identity, missing),
missing=missing,
path=item_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result_by_id[identity] = merged.value
order_source = local_order if local_reordered and not current_reordered else current_order
merged_order = [identity for identity in order_source if identity in result_by_id]
for identity in identities:
if identity in result_by_id and identity not in merged_order:
merged_order.append(identity)
return ThreeWayMergeResult(
value=[result_by_id[identity] for identity in merged_order],
conflicts=conflicts,
applied_paths=applied_paths,
)
def _stable_identity_field(
values: Sequence[list[Any]],
candidates: Sequence[str],
) -> str | None:
all_items = [item for value in values for item in value]
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
return None
for candidate in candidates:
valid = True
for value in values:
identities = [
str(item.get(candidate, "")).strip()
for item in value
if isinstance(item, Mapping)
]
if any(not identity for identity in identities) or len(identities) != len(
set(identities)
):
valid = False
break
if valid:
return candidate
return None
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
right_set = set(right)
return [item for item in left if item in right_set]
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
normalized = path or "/"
for protected in protected_paths:
if callable(protected):
if protected(normalized):
return True
continue
prefix = protected.rstrip("/") or "/"
if normalized == prefix or normalized.startswith(f"{prefix}/"):
return True
return False
def _join_path(parent: str, segment: str) -> str:
escaped = segment.replace("~", "~0").replace("/", "~1")
return f"{parent}/{escaped}" if parent else f"/{escaped}"
def _conflict(
path: str,
kind: str,
base: Any,
local: Any,
current: Any,
) -> ThreeWayMergeResult:
return ThreeWayMergeResult(
value=current,
conflicts=[
MergeConflict(
path=path or "/",
kind=kind,
base_value=base,
local_value=local,
current_value=current,
)
],
)
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
if depth >= 4:
return {"summary": type(value).__name__}
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return value[:500]
if isinstance(value, Mapping):
result = {
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
for key, item in list(value.items())[:20]
}
if len(value) > 20:
result["_truncated_items"] = len(value) - 20
return result
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
result = [
_bounded_json_value(item, depth=depth + 1)
for item in list(value)[:20]
]
if len(value) > 20:
result.append({"_truncated_items": len(value) - 20})
return result
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return {"summary": type(value).__name__}
__all__ = [
"ConcurrencyError",
"MergeConflict",
"MissingPreconditionError",
"RevisionConflictError",
"ThreeWayMergeResult",
"assert_revision_precondition",
"claim_revision",
"if_match_matches",
"strong_resource_etag",
"three_way_merge",
]
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 25,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
limit: int = 250,
) -> Mapping[str, object]:
...
@@ -3328,12 +3328,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
return payload
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
if not command:
return None
return _run_restart_command(command)
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
raw_plan = record.get("plan")
if not isinstance(raw_plan, list):
+50
View File
@@ -215,6 +215,17 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationSourceKind = Literal[
"manifest",
"route",
"capability",
"policy",
"configuration_package",
"wiki",
"repository",
]
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
@dataclass(frozen=True, slots=True)
@@ -296,6 +307,40 @@ class DocumentationContext:
data: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationDecision:
key: str
state: DocumentationConfigurationState
source: str | None = None
reason: str | None = None
DocumentationConfigurationResolver = Callable[
[DocumentationContext, tuple[str, ...]],
Mapping[str, DocumentationConfigurationDecision],
]
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationProviderRegistration:
keys: tuple[str, ...]
resolve: DocumentationConfigurationResolver
@dataclass(frozen=True, slots=True)
class DocumentationSourceDefinition:
id: str
kind: DocumentationSourceKind
label: str
documentation_types: tuple[DocumentationType, ...] = ("admin",)
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
state: DocumentationSourceState = "configured"
provenance: Mapping[str, Any] = field(default_factory=dict)
inspection: Mapping[str, Any] = field(default_factory=dict)
link: DocumentationLink | None = None
configuration_key: str | None = None
class ResourceAclProvider(Protocol):
resource_type: str
@@ -372,3 +417,8 @@ class ModuleManifest:
on_deactivate: LifecycleHook | None = None
documentation: tuple[DocumentationTopic, ...] = ()
documentation_providers: tuple[DocumentationProvider, ...] = ()
documentation_configuration_providers: tuple[
DocumentationConfigurationProviderRegistration,
...,
] = ()
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
+35
View File
@@ -16,6 +16,7 @@ CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
@dataclass(frozen=True, slots=True)
@@ -140,6 +141,7 @@ class PostboxMessageRef:
subject: str
body_text: str | None
status: str
availability: PostboxMessageAvailability
classification: str
sender_label: str | None
delivered_at: datetime
@@ -159,6 +161,29 @@ class PostboxMessageRef:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryReceiptSummaryRef:
delivery_id: str
message_id: str
postbox_id: str
delivery_status: str
accepted_at: datetime
current_holder_count: int = 0
currently_readable: bool = False
message_count: int = 1
routed_message_count: int = 0
readable_message_count: int = 0
read_receipt_count: int = 0
acknowledged_receipt_count: int = 0
withdrawn_message_count: int = 0
expired_message_count: int = 0
first_read_at: datetime | None = None
last_read_at: datetime | None = None
first_acknowledged_at: datetime | None = None
last_acknowledged_at: datetime | None = None
route_status_counts: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
@@ -317,6 +342,16 @@ class PostboxEvidenceProvider(Protocol):
) -> PostboxMessageRef:
...
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
producer_module: str,
delivery_ids: Sequence[str],
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
...
@runtime_checkable
class PostboxRoutingProvider(Protocol):
+37
View File
@@ -564,6 +564,43 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider must declare keys"
)
for raw_key in registration.keys:
key = raw_key.strip()
if not key:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider declares an empty key"
)
if key in provider_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation configuration key {key!r}"
)
provider_keys.add(key)
source_ids: set[str] = set()
for source in manifest.documentation_sources:
if not _INTERFACE_NAME_RE.match(source.id):
raise RegistryError(
f"Module {manifest.id!r} documentation source id must be namespaced: {source.id!r}"
)
if source.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation source {source.id!r}"
)
source_ids.add(source.id)
if not source.label.strip():
raise RegistryError(
f"Module {manifest.id!r} documentation source {source.id!r} must have a label"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
+13 -10
View File
@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> o
return result
if isinstance(value, list):
return [redact_secret_values(item, placeholder=placeholder) for item in value]
if isinstance(value, tuple):
return tuple(redact_secret_values(item, placeholder=placeholder) for item in value)
return value
def contains_plain_secret(value: object) -> bool:
if not isinstance(value, Mapping):
return False
for key, item in value.items():
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if isinstance(item, Mapping) and contains_plain_secret(item):
return True
if isinstance(value, Mapping):
for key, item in value.items():
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if contains_plain_secret(item):
return True
elif isinstance(value, (list, tuple)):
return any(contains_plain_secret(item) for item in value)
return False