"""Safe JSON encoding helper. Phase 1k++ refinement (N5/R4): we used to pass ``default=str`` to ``json.dumps`` so nested datetime/Decimal fields didn't crash the write AFTER the agent had done its real work. But ``default=str`` is too permissive — a worker that accidentally emits a ``set`` or a custom class gets silently stringified to ``""``, masking a real output bug. This module ships a restricted encoder that: - Encodes ``datetime`` and ``date`` as ISO-8601 strings. - Encodes ``Decimal`` as its string repr (preserves precision). - Encodes ``UUID`` as its canonical string form. - Encodes ``Path`` as its string form. - Raises ``TypeError`` for anything else — including ``set`` / ``frozenset`` (round 3 R4: silent set→list coercion contradicted the "raise loudly" design intent because JSON has no native set and a reader doing ``parsed["tags"]`` would get a list, losing set algebra. Callers that genuinely want a JSON array should call ``sorted(list(my_set))`` explicitly). Scope (round-3 R8): this encoder is used at the boundaries where WORKER-ORIGINATED payloads are persisted — i.e., the runner's output-write and the scheduler's input_payload write. Other ``json.dumps`` call sites in the controller (event-row payloads, discovery markers, etc.) serialize fixed-shape dicts of native types and don't need restriction. """ from __future__ import annotations import json from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Any from uuid import UUID # Public list of types this encoder coerces. Anything else → TypeError. _SAFE_TYPES = (datetime, date, Decimal, UUID, Path) def _restricted_default(value: Any) -> Any: if isinstance(value, datetime): return value.isoformat() if isinstance(value, date): return value.isoformat() if isinstance(value, Decimal): return str(value) if isinstance(value, UUID): return str(value) if isinstance(value, Path): return str(value) raise TypeError( f"Object of type {type(value).__name__} is not JSON-serializable " f"and not in the controller's safe-type allowlist " f"({', '.join(t.__name__ for t in _SAFE_TYPES)}). " f"To emit a set as a JSON array, call sorted(list(...)) " f"explicitly at the producer." ) def safe_json_dumps(obj: Any, **kwargs: Any) -> str: """json.dumps with a restricted ``default`` that handles the allowlist + raises for anything else. Extra kwargs are forwarded to ``json.dumps``.""" return json.dumps(obj, default=_restricted_default, **kwargs) __all__ = ["safe_json_dumps"]