openbox-sdk-python 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,129 @@
1
+ """Byte/transform-only JSON serialization.
2
+
3
+ This module knows NOTHING about events or spans — evaluate-body assembly
4
+ (``span_count``, compat-noise removal) is owned by ``wire/evaluate_payload.py``.
5
+ It owns exactly:
6
+
7
+ - ``to_json_safe`` — dataclass/enum/datetime-tolerant JSON coercion
8
+ - ``serialize_body`` — the EXACT bytes that are signed and transmitted
9
+ - ``truncate_string`` / ``apply_redaction`` — pre-signing transforms
10
+ - ``rfc3339_now`` — wall-clock helper (this module is NOT sandbox-pure)
11
+
12
+ Wall-clock lives here (not in contracts) so pure modules stay deterministic.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import json
19
+ from datetime import UTC, datetime
20
+ from enum import Enum
21
+ from typing import Any
22
+
23
+ __all__ = [
24
+ "to_json_safe",
25
+ "serialize_body",
26
+ "truncate_string",
27
+ "apply_redaction",
28
+ "rfc3339_now",
29
+ "REDACTED_PLACEHOLDER",
30
+ ]
31
+
32
+ REDACTED_PLACEHOLDER = "[REDACTED]"
33
+
34
+
35
+ def rfc3339_now() -> str:
36
+ """Current UTC time, RFC3339 with millisecond precision and trailing ``Z``.
37
+
38
+ Event-payload timestamp format — distinct from the request-*signing*
39
+ timestamp which keeps ``+00:00`` (see ``identity.py``).
40
+ """
41
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
42
+
43
+
44
+ def to_json_safe(obj: Any, exclude_none: bool = True) -> Any:
45
+ """Recursively coerce ``obj`` into JSON-serializable primitives.
46
+
47
+ Handles dataclasses (as dicts), Enums (``.value``), datetimes (RFC3339),
48
+ sets/tuples (lists), and dict keys via ``str()``. Unknown objects fall back
49
+ to ``str(obj)`` — governance telemetry must never crash the host app over
50
+ an exotic payload type.
51
+ """
52
+ if obj is None or isinstance(obj, (str, int, float, bool)):
53
+ return obj
54
+ if isinstance(obj, Enum):
55
+ return to_json_safe(obj.value, exclude_none)
56
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
57
+ return to_json_safe(dataclasses.asdict(obj), exclude_none)
58
+ if isinstance(obj, datetime):
59
+ if obj.tzinfo is None:
60
+ obj = obj.replace(tzinfo=UTC)
61
+ return obj.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
62
+ if isinstance(obj, dict):
63
+ return {
64
+ str(k): to_json_safe(v, exclude_none)
65
+ for k, v in obj.items()
66
+ if not (exclude_none and v is None)
67
+ }
68
+ if isinstance(obj, (list, tuple, set, frozenset)):
69
+ return [to_json_safe(v, exclude_none) for v in obj]
70
+ if isinstance(obj, bytes):
71
+ return obj.decode("utf-8", errors="replace")
72
+ return str(obj)
73
+
74
+
75
+ def serialize_body(payload: dict | None) -> bytes:
76
+ """Serialize a payload to the EXACT bytes that will be transmitted.
77
+
78
+ - ``None`` -> ``b""`` (body hash becomes the empty-body SHA-256).
79
+ - Compact separators (no spaces) and a single serialization pass so the
80
+ bytes we hash are identical to the bytes we send. Re-serializing with
81
+ ``json=`` elsewhere would break Core's body-hash verification.
82
+ """
83
+ if payload is None:
84
+ return b""
85
+ return json.dumps(payload, separators=(",", ":")).encode("utf-8")
86
+
87
+
88
+ def truncate_string(value: str, max_size: int | None) -> tuple[str, bool]:
89
+ """Truncate ``value`` to ``max_size`` chars. Returns (value, truncated?).
90
+
91
+ ``None``/non-positive ``max_size`` disables truncation. Applied BEFORE
92
+ signing — the signed bytes are the truncated bytes.
93
+ """
94
+ if not max_size or max_size <= 0 or len(value) <= max_size:
95
+ return value, False
96
+ return value[:max_size], True
97
+
98
+
99
+ def apply_redaction(
100
+ obj: Any,
101
+ redact_keys: frozenset[str] | set[str],
102
+ replacement: str = REDACTED_PLACEHOLDER,
103
+ ) -> tuple[Any, list[str]]:
104
+ """Replace values of case-insensitive key matches anywhere in ``obj``.
105
+
106
+ Returns ``(redacted_copy, changed_paths)`` — the paths identify what
107
+ changed so callers can attach diagnostics. Applied BEFORE signing.
108
+ """
109
+ if not redact_keys:
110
+ return obj, []
111
+ lowered = {k.lower() for k in redact_keys}
112
+ changed: list[str] = []
113
+
114
+ def _walk(node: Any, path: str) -> Any:
115
+ if isinstance(node, dict):
116
+ out = {}
117
+ for k, v in node.items():
118
+ child_path = f"{path}.{k}" if path else str(k)
119
+ if isinstance(k, str) and k.lower() in lowered:
120
+ out[k] = replacement
121
+ changed.append(child_path)
122
+ else:
123
+ out[k] = _walk(v, child_path)
124
+ return out
125
+ if isinstance(node, (list, tuple)):
126
+ return [_walk(v, f"{path}[{i}]") for i, v in enumerate(node)]
127
+ return node
128
+
129
+ return _walk(obj, ""), changed
@@ -0,0 +1,3 @@
1
+ """Strict-gate validation rules and diagnostics."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,60 @@
1
+ """Structured diagnostic records attached to evaluation results.
2
+
3
+ Diagnostics are the NON-FAIL half of validation: they record best-effort
4
+ degradations (missing semantic attributes, compat-noise removal, redaction)
5
+ without rejecting anything. Strict failures raise ContractError instead —
6
+ there is no diagnostic for a contract violation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "DiagnosticLevel",
17
+ "Diagnostic",
18
+ "SPAN_ATTR_MISSING",
19
+ "COMPAT_NOISE_REMOVED",
20
+ "ATTR_REDACTED",
21
+ "ATTR_TRUNCATED",
22
+ "HOOK_SKIPPED_NO_CONTEXT",
23
+ ]
24
+
25
+ # Diagnostic codes (stable machine identifiers)
26
+ SPAN_ATTR_MISSING = "SPAN_ATTR_MISSING"
27
+ COMPAT_NOISE_REMOVED = "COMPAT_NOISE_REMOVED"
28
+ ATTR_REDACTED = "ATTR_REDACTED"
29
+ ATTR_TRUNCATED = "ATTR_TRUNCATED"
30
+ HOOK_SKIPPED_NO_CONTEXT = "HOOK_SKIPPED_NO_CONTEXT"
31
+
32
+
33
+ class DiagnosticLevel(str, Enum):
34
+ INFO = "INFO"
35
+ WARNING = "WARNING"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Diagnostic:
40
+ """One structured diagnostic record.
41
+
42
+ Attributes:
43
+ level: Severity (INFO/WARNING) — never an error (errors raise).
44
+ code: Stable machine code (e.g. ``SPAN_ATTR_MISSING``).
45
+ message: Human-readable summary.
46
+ detail: Structured context (what changed, which key, which span).
47
+ """
48
+
49
+ level: DiagnosticLevel
50
+ code: str
51
+ message: str
52
+ detail: dict[str, Any] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "level": self.level.value,
57
+ "code": self.code,
58
+ "message": self.message,
59
+ "detail": dict(self.detail),
60
+ }
@@ -0,0 +1,164 @@
1
+ """Strict contract failures — raise ContractError BEFORE any network send.
2
+
3
+ The gate is ALWAYS strict for OpenBox event contracts and runtime invariants;
4
+ there is no OBSERVE/SANITIZE/STRICT mode and no way to downgrade these to
5
+ diagnostics. Fail-open applies only to network errors, never here.
6
+
7
+ Strict failure list:
8
+ - malformed lifecycle event envelope
9
+ - hook event has ``hook_trigger=false`` (span-bearing but not marked hook)
10
+ - hook event uses the wrong wire event type
11
+ - ``preflight()`` receives completed-stage spans
12
+ - ``completed()`` receives started-stage spans
13
+ - ``ActivityCompleted`` contains non-empty hook span payloads
14
+ - instrumentation produced an impossible/malformed hook event
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from ..contracts.events import EventEnvelope, EventKind, EventType, classify_event
22
+ from ..errors import ContractError
23
+
24
+ __all__ = [
25
+ "span_stage",
26
+ "check_lifecycle_envelope",
27
+ "check_hook_envelope",
28
+ "check_stage",
29
+ ]
30
+
31
+ # Payload fields every workflow-scoped lifecycle event must carry.
32
+ _REQUIRED_WORKFLOW_FIELDS = ("workflow_id", "run_id", "workflow_type")
33
+ _REQUIRED_HANDOFF_FIELDS = ("from_agent_did", "multi_agent_session_id")
34
+
35
+
36
+ def span_stage(span: Any) -> str | None:
37
+ """Extract the stage from a span payload.
38
+
39
+ Spans are flat-only: the stage lives at the top level. Enum values normalize
40
+ via ``.value``.
41
+ """
42
+ if isinstance(span, dict):
43
+ stage: Any = span.get("stage")
44
+ else:
45
+ stage = getattr(span, "stage", None)
46
+ value = getattr(stage, "value", stage)
47
+ return value if isinstance(value, str) else None
48
+
49
+
50
+ def _require_fields(event: EventEnvelope, fields: tuple[str, ...], what: str) -> None:
51
+ missing = [f for f in fields if not event.payload.get(f)]
52
+ if missing:
53
+ raise ContractError(
54
+ f"Malformed {what} envelope: missing required fields {missing}",
55
+ code="ENVELOPE_MISSING_FIELDS",
56
+ detail={"missing": missing, "event_type": event.event_type.value},
57
+ )
58
+
59
+
60
+ def check_lifecycle_envelope(event: EventEnvelope) -> None:
61
+ """Strict checks for non-hook envelopes (lifecycle/signal/handoff)."""
62
+ if not isinstance(event.event_type, EventType):
63
+ raise ContractError(
64
+ f"Malformed envelope: event_type must be an EventType, got {type(event.event_type).__name__}",
65
+ code="ENVELOPE_BAD_EVENT_TYPE",
66
+ )
67
+
68
+ kind = classify_event(event)
69
+ if kind is EventKind.HOOK:
70
+ raise ContractError(
71
+ "check_lifecycle_envelope received a hook event — route hook events "
72
+ "through preflight()/completed()",
73
+ code="HOOK_ON_LIFECYCLE_PATH",
74
+ )
75
+
76
+ # A span-bearing envelope that is not marked as a hook is malformed:
77
+ # either it IS a hook (then hook_trigger must be true) or spans are noise.
78
+ if event.spans:
79
+ if event.event_type is EventType.ACTIVITY_COMPLETED:
80
+ raise ContractError(
81
+ "ActivityCompleted must not carry non-empty hook spans — completed "
82
+ "telemetry routes through completed() with completed-stage spans",
83
+ code="ACTIVITY_COMPLETED_WITH_SPANS",
84
+ detail={"span_count": len(event.spans)},
85
+ )
86
+ raise ContractError(
87
+ f"{event.event_type.value} carries spans but hook_trigger=false — "
88
+ "span-bearing evaluations must be hook events",
89
+ code="HOOK_TRIGGER_FALSE",
90
+ detail={"span_count": len(event.spans)},
91
+ )
92
+
93
+ if kind is EventKind.HANDOFF:
94
+ _require_fields(event, _REQUIRED_HANDOFF_FIELDS, "handoff")
95
+ return
96
+ _require_fields(event, _REQUIRED_WORKFLOW_FIELDS, "lifecycle")
97
+ if kind is EventKind.SIGNAL and not event.payload.get("signal_name"):
98
+ raise ContractError(
99
+ "Malformed signal envelope: missing signal_name",
100
+ code="ENVELOPE_MISSING_FIELDS",
101
+ detail={"missing": ["signal_name"]},
102
+ )
103
+
104
+
105
+ def check_hook_envelope(event: EventEnvelope) -> None:
106
+ """Strict checks for hook (span-bearing) envelopes."""
107
+ if not event.hook_trigger:
108
+ raise ContractError(
109
+ "Hook evaluation requires hook_trigger=true",
110
+ code="HOOK_TRIGGER_FALSE",
111
+ )
112
+ if event.event_type is not EventType.ACTIVITY_STARTED:
113
+ raise ContractError(
114
+ f"Hook events must use wire event type ActivityStarted, got "
115
+ f"{event.event_type.value}",
116
+ code="HOOK_WRONG_WIRE_TYPE",
117
+ detail={"event_type": event.event_type.value},
118
+ )
119
+ if not event.spans:
120
+ raise ContractError(
121
+ "Hook event carries no spans — instrumentation produced an impossible "
122
+ "hook event",
123
+ code="HOOK_EMPTY_SPANS",
124
+ )
125
+ if not event.activity_id or not event.activity_type:
126
+ raise ContractError(
127
+ "Hook event is not attached to a bound activity (activity_id and "
128
+ "activity_type are required)",
129
+ code="HOOK_UNBOUND_ACTIVITY",
130
+ detail={
131
+ "activity_id": event.activity_id,
132
+ "activity_type": event.activity_type,
133
+ },
134
+ )
135
+ for index, span in enumerate(event.spans):
136
+ if isinstance(span, dict):
137
+ forbidden = sorted({"otel", "openbox", "data"} & set(span))
138
+ if forbidden:
139
+ raise ContractError(
140
+ "Hook spans must be flat Core SpanData dicts — nested/debug "
141
+ f"keys are not allowed at spans[{index}]: {forbidden}",
142
+ code="HOOK_SPAN_NOT_FLAT",
143
+ detail={"index": index, "forbidden": forbidden},
144
+ )
145
+
146
+
147
+ def check_stage(event: EventEnvelope, expected_stage: str) -> None:
148
+ """Reject stage-mismatched spans (preflight=started, completed=completed)."""
149
+ for index, span in enumerate(event.spans):
150
+ stage = span_stage(span)
151
+ if stage is None:
152
+ raise ContractError(
153
+ f"Hook span[{index}] has no stage — instrumentation produced a "
154
+ "malformed hook span",
155
+ code="HOOK_SPAN_NO_STAGE",
156
+ detail={"index": index},
157
+ )
158
+ if stage != expected_stage:
159
+ raise ContractError(
160
+ f"{expected_stage}-stage evaluation received a {stage}-stage span "
161
+ f"at spans[{index}]",
162
+ code="HOOK_STAGE_MISMATCH",
163
+ detail={"index": index, "expected": expected_stage, "actual": stage},
164
+ )
@@ -0,0 +1,31 @@
1
+ """Validation dispatch — route an envelope to its rule set, aggregate diagnostics.
2
+
3
+ Strict failures raise ContractError (before any send); the return value is the
4
+ list of non-fail diagnostics collected along the way.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..contracts.events import EventEnvelope
10
+ from .diagnostics import Diagnostic
11
+ from .event_rules import check_hook_envelope, check_lifecycle_envelope, check_stage
12
+
13
+ __all__ = ["validate_lifecycle", "validate_hook"]
14
+
15
+
16
+ def validate_lifecycle(event: EventEnvelope) -> list[Diagnostic]:
17
+ """Validate a lifecycle/signal/handoff envelope. Raises ContractError on
18
+ violation; returns diagnostics (none today — reserved for future rules)."""
19
+ check_lifecycle_envelope(event)
20
+ return []
21
+
22
+
23
+ def validate_hook(event: EventEnvelope, expected_stage: str) -> list[Diagnostic]:
24
+ """Validate a hook envelope for the given stage ("started"/"completed").
25
+
26
+ ``check_hook_envelope``'s first rule rejects ``hook_trigger=false``, which
27
+ also covers non-HOOK envelopes routed here by mistake.
28
+ """
29
+ check_hook_envelope(event)
30
+ check_stage(event, expected_stage)
31
+ return []
@@ -0,0 +1,107 @@
1
+ """Non-fail span diagnostics — best-effort degradations that never reject.
2
+
3
+ Missing semantic attributes, compatibility noise, and privacy transforms are
4
+ DIAGNOSTICS, not failures. Only malformed envelopes/stages/bindings are strict
5
+ (see event_rules.py). Semantic gaps never reject or drop a span.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .diagnostics import (
13
+ ATTR_REDACTED,
14
+ ATTR_TRUNCATED,
15
+ COMPAT_NOISE_REMOVED,
16
+ SPAN_ATTR_MISSING,
17
+ Diagnostic,
18
+ DiagnosticLevel,
19
+ )
20
+
21
+ __all__ = [
22
+ "strip_compat_noise",
23
+ "semantic_gap_diagnostics",
24
+ "redaction_diagnostics",
25
+ "truncation_diagnostic",
26
+ "SEMANTIC_FIELDS_BY_HOOK_TYPE",
27
+ ]
28
+
29
+ # Best-effort semantic fields per hook type — absence is an INFO diagnostic.
30
+ SEMANTIC_FIELDS_BY_HOOK_TYPE: dict[str, tuple[str, ...]] = {
31
+ "http_request": ("http_method", "http_url"),
32
+ "db_query": ("db_system", "db_statement"),
33
+ "file_operation": ("file_path", "file_operation"),
34
+ "function_call": ("function", "module"),
35
+ }
36
+
37
+
38
+ def strip_compat_noise(payload: dict[str, Any]) -> tuple[dict[str, Any], list[Diagnostic]]:
39
+ """Remove ``spans=[]``/``span_count=0`` from a lifecycle wire payload.
40
+
41
+ Core ignores these empty fields, but they are contract noise. Removal is
42
+ recorded as a diagnostic — it is NOT a configurable mode. Non-empty spans
43
+ are not touched here (they are a strict failure upstream).
44
+ """
45
+ diagnostics: list[Diagnostic] = []
46
+ removed: list[str] = []
47
+ cleaned = dict(payload)
48
+ if "spans" in cleaned and cleaned["spans"] == []:
49
+ del cleaned["spans"]
50
+ removed.append("spans")
51
+ if "span_count" in cleaned and cleaned["span_count"] == 0:
52
+ del cleaned["span_count"]
53
+ removed.append("span_count")
54
+ if removed:
55
+ diagnostics.append(
56
+ Diagnostic(
57
+ level=DiagnosticLevel.INFO,
58
+ code=COMPAT_NOISE_REMOVED,
59
+ message=f"Removed compatibility noise before send: {removed}",
60
+ detail={"removed": removed},
61
+ )
62
+ )
63
+ return cleaned, diagnostics
64
+
65
+
66
+ def semantic_gap_diagnostics(
67
+ span_wire: dict[str, Any], hook_type: str | None
68
+ ) -> list[Diagnostic]:
69
+ """INFO diagnostics for missing best-effort semantic fields.
70
+
71
+ The span is still sent — semantic gaps NEVER reject a span.
72
+ """
73
+ fields = SEMANTIC_FIELDS_BY_HOOK_TYPE.get(hook_type or "", ())
74
+ return [
75
+ Diagnostic(
76
+ level=DiagnosticLevel.INFO,
77
+ code=SPAN_ATTR_MISSING,
78
+ message=f"Best-effort semantic attribute missing: {field_name}",
79
+ detail={"hook_type": hook_type, "field": field_name},
80
+ )
81
+ for field_name in fields
82
+ if span_wire.get(field_name) is None
83
+ ]
84
+
85
+
86
+ def redaction_diagnostics(changed_paths: list[str]) -> list[Diagnostic]:
87
+ """Diagnostics identifying exactly what redaction changed."""
88
+ if not changed_paths:
89
+ return []
90
+ return [
91
+ Diagnostic(
92
+ level=DiagnosticLevel.INFO,
93
+ code=ATTR_REDACTED,
94
+ message=f"Redacted {len(changed_paths)} value(s) before send",
95
+ detail={"paths": list(changed_paths)},
96
+ )
97
+ ]
98
+
99
+
100
+ def truncation_diagnostic(field_name: str, original_size: int, max_size: int) -> Diagnostic:
101
+ """Diagnostic identifying a truncated value."""
102
+ return Diagnostic(
103
+ level=DiagnosticLevel.INFO,
104
+ code=ATTR_TRUNCATED,
105
+ message=f"Truncated {field_name} from {original_size} to {max_size} chars",
106
+ detail={"field": field_name, "original_size": original_size, "max_size": max_size},
107
+ )
@@ -0,0 +1,3 @@
1
+ """Wire-format serializers targeting the current OpenBox Core API contracts."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,130 @@
1
+ """to_core_span_data — normalize flat Core ``SpanData`` wire spans.
2
+
3
+ Wire rules:
4
+
5
+ - Ids are HEX STRINGS: span_id 16, trace_id 32, parent_span_id 16 chars.
6
+ Raw integer OTel ids must never be sent.
7
+ - Timestamps are epoch NANOSECONDS.
8
+ - started-stage spans emit EXPLICIT ``end_time: null`` and
9
+ ``duration_ns: null`` (never omitted, never ``end_time == start_time``);
10
+ Core's non-pointer ``EndTime int64`` unmarshals null -> 0.
11
+ - The COMMON root fields (span_id, trace_id, parent_span_id, name, kind,
12
+ stage, start_time, end_time, duration_ns, attributes, status, events,
13
+ hook_type, error) are ALWAYS present — null-valued when absent, never
14
+ omitted — matching the flat hook contract.
15
+ - Each hook family's own root fields (http_*/db_*/file_*/function) are ALSO
16
+ always present for that family (null when the wrapper/attributes did not
17
+ supply them). ``attributes`` carries OTel-native attributes ONLY.
18
+ - Semantic HTTP/DB/file/function fields are best-effort from OTel attributes
19
+ during ``contracts.otel_spans.from_otel_span``; absence is a diagnostic,
20
+ NEVER a rejection.
21
+ - ``semantic_type`` is NEVER set here — Core computes it.
22
+ - Hook spans are FLAT in memory and on the wire: no ``data`` blob and no nested
23
+ ``{"otel", "openbox"}`` envelope.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ from ..config import PrivacyConfig
31
+ from ..contracts.otel_spans import _ROOT_FIELDS_BY_HOOK_TYPE
32
+ from ..serialization import apply_redaction, truncate_string
33
+ from ..validation.diagnostics import Diagnostic
34
+ from ..validation.span_normalization import (
35
+ redaction_diagnostics,
36
+ semantic_gap_diagnostics,
37
+ truncation_diagnostic,
38
+ )
39
+
40
+ __all__ = ["to_core_span_data"]
41
+
42
+ # Wrapper-supplied fields eligible for body truncation.
43
+ _TRUNCATABLE_FIELDS = ("request_body", "response_body")
44
+
45
+ _COMMON_DEFAULTS: dict[str, Any] = {
46
+ "span_id": "0" * 16,
47
+ "trace_id": "0" * 32,
48
+ "parent_span_id": None,
49
+ "name": "span",
50
+ "kind": "INTERNAL",
51
+ "start_time": None,
52
+ "end_time": None,
53
+ "duration_ns": None,
54
+ "attributes": {},
55
+ "status": {"code": "UNSET", "description": None},
56
+ "events": [],
57
+ "error": None,
58
+ }
59
+
60
+
61
+ def to_core_span_data(
62
+ span: dict[str, Any],
63
+ *,
64
+ privacy: PrivacyConfig | None = None,
65
+ include_otel_data: bool = False,
66
+ ) -> tuple[dict[str, Any], list[Diagnostic]]:
67
+ """Normalize one flat Core ``SpanData`` dict.
68
+
69
+ Returns ``(wire_span, diagnostics)``. Missing semantic attributes are
70
+ diagnostics, never failures; redaction/truncation is recorded.
71
+
72
+ ``include_otel_data`` is retained as a compatibility parameter but is now a
73
+ no-op: spans stay flat everywhere.
74
+ """
75
+ _ = include_otel_data
76
+ diagnostics: list[Diagnostic] = []
77
+ wire = dict(span)
78
+ # Never let nested/debug shapes leak forward.
79
+ wire.pop("otel", None)
80
+ wire.pop("openbox", None)
81
+ wire.pop("data", None)
82
+ wire.pop("metadata", None)
83
+
84
+ hook_type = wire.get("hook_type")
85
+ attributes = dict(wire.get("attributes") or {})
86
+ if privacy and privacy.redact_keys:
87
+ attributes, changed = apply_redaction(attributes, privacy.redact_keys)
88
+ diagnostics.extend(redaction_diagnostics([f"attributes.{p}" for p in changed]))
89
+ wire["attributes"] = attributes
90
+
91
+ for field_name in _TRUNCATABLE_FIELDS:
92
+ if field_name not in wire:
93
+ continue
94
+ value = wire.get(field_name)
95
+ if privacy and field_name in _TRUNCATABLE_FIELDS and isinstance(value, str):
96
+ truncated, was_truncated = truncate_string(value, privacy.max_body_size)
97
+ if was_truncated:
98
+ diagnostics.append(
99
+ truncation_diagnostic(field_name, len(value), privacy.max_body_size)
100
+ )
101
+ value = truncated
102
+ wire[field_name] = value
103
+
104
+ for field_name, value in _COMMON_DEFAULTS.items():
105
+ if field_name in wire:
106
+ continue
107
+ if isinstance(value, dict):
108
+ wire[field_name] = dict(value)
109
+ elif isinstance(value, list):
110
+ wire[field_name] = list(value)
111
+ else:
112
+ wire[field_name] = value
113
+
114
+ # Guarantee every family-specific root key exists (explicit null if neither
115
+ # attributes nor the wrapper supplied it); the flat hook contract emits the
116
+ # full family key set, and Core's ``omitempty`` tolerates the nulls.
117
+ for field_name in _ROOT_FIELDS_BY_HOOK_TYPE.get(hook_type or "", ()):
118
+ wire.setdefault(field_name, None)
119
+
120
+ # OTel-owned HTTP spans are not always ended when the response hook fires,
121
+ # so ``end_time`` may be null even though a duration was measured.
122
+ # Reconstruct it from start_time + duration for completed spans.
123
+ if wire.get("stage") != "started" and wire.get("end_time") is None:
124
+ start_time = wire.get("start_time")
125
+ measured = wire.get("duration_ns")
126
+ if isinstance(start_time, int) and isinstance(measured, int):
127
+ wire["end_time"] = start_time + measured
128
+
129
+ diagnostics.extend(semantic_gap_diagnostics(wire, hook_type))
130
+ return wire, diagnostics
@@ -0,0 +1,56 @@
1
+ """build_evaluate_payload — the SINGLE owner of the evaluate request body.
2
+
3
+ Assembles the exact ``/api/v1/governance/evaluate`` body for hook events:
4
+ the ``EventEnvelope`` fields at the top level (``event_type=ActivityStarted``,
5
+ ``hook_trigger=true``, ``activity_id``/``activity_type``, ``timestamp``),
6
+ ``spans`` as a list of flat Core ``SpanData`` dicts, and ``span_count``.
7
+
8
+ The strict gate injects this as its ``payload_builder`` — the gate never
9
+ reimplements body assembly, and ``serialization.serialize_body`` (byte-only)
10
+ then produces the signed bytes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from ..config import PrivacyConfig
18
+ from ..contracts.events import EventEnvelope
19
+ from ..validation.diagnostics import Diagnostic
20
+ from .core_span import to_core_span_data
21
+
22
+ __all__ = ["build_evaluate_payload", "make_payload_builder"]
23
+
24
+
25
+ def build_evaluate_payload(
26
+ event: EventEnvelope,
27
+ *,
28
+ privacy: PrivacyConfig | None = None,
29
+ ) -> tuple[dict[str, Any], list[Diagnostic]]:
30
+ """Assemble the hook evaluate body from a validated hook envelope.
31
+
32
+ Spans are flat Core ``SpanData`` dicts in memory. The normalizer enforces
33
+ the final no-nested/no-data wire shape and applies privacy transforms.
34
+ """
35
+ diagnostics: list[Diagnostic] = []
36
+ wire_spans: list[dict[str, Any]] = []
37
+ for span in event.spans:
38
+ wire_span, span_diagnostics = to_core_span_data(
39
+ dict(span), privacy=privacy, include_otel_data=False
40
+ )
41
+ diagnostics.extend(span_diagnostics)
42
+ wire_spans.append(wire_span)
43
+
44
+ payload = event.to_payload_dict()
45
+ payload["spans"] = wire_spans
46
+ payload["span_count"] = len(wire_spans)
47
+ return payload, diagnostics
48
+
49
+
50
+ def make_payload_builder(privacy: PrivacyConfig | None = None):
51
+ """Bind a privacy config into the gate's single-argument builder seam."""
52
+
53
+ def _builder(event: EventEnvelope) -> tuple[dict[str, Any], list[Diagnostic]]:
54
+ return build_evaluate_payload(event, privacy=privacy)
55
+
56
+ return _builder