|
| 1 | +"""The ``invocation_context`` companion to the settings envelope. |
| 2 | +
|
| 3 | +Where ``invocation_settings`` carries *what* a plugin should be configured with, the context |
| 4 | +carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read |
| 5 | +from its process environment. It travels in a second reserved, out-of-schema field of the |
| 6 | +``/invoke`` body, extracted by the same middleware that resolves the settings field. |
| 7 | +
|
| 8 | +The context is `/invoke` protocol identity, not settings security: it touches no crypto and no |
| 9 | +secrets, and it evolves with the plugin protocol this package defines. The errors it raises come |
| 10 | +from the shared ``InvocationSettingsError`` taxonomy so hosts classify context failures with the |
| 11 | +same ``reason``/``blame`` machinery as settings failures. |
| 12 | +
|
| 13 | +The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are |
| 14 | +preserved so a newer producer does not break an older plugin, and every identity field is optional |
| 15 | +so a partially-populated context degrades to "less telemetry" rather than a failed invoke. The one |
| 16 | +thing it is strict about is ``schema_version`` — that field exists to make an incompatible producer |
| 17 | +detectable, which it can only do if somebody actually reads it. The payload is what carries the |
| 18 | +version, not the route, so evolving the contract does not mean adding endpoints. |
| 19 | +""" |
| 20 | + |
| 21 | +from __future__ import annotations |
| 22 | + |
| 23 | +from typing import Any, Mapping |
| 24 | + |
| 25 | +import pydantic |
| 26 | +from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError |
| 27 | + |
| 28 | +# Reserved key carrying the invocation context in the invoke request body. |
| 29 | +RESERVED_CONTEXT_KEY = "invocation_context" |
| 30 | + |
| 31 | +# Context payload versions this package understands. Additive keys do not bump this; a change that |
| 32 | +# would make an old consumer misread an existing key does. |
| 33 | +SUPPORTED_CONTEXT_VERSIONS = frozenset({"1"}) |
| 34 | + |
| 35 | +# The identity facets that become telemetry dimensions. Shared rather than per-service policy: |
| 36 | +# every hop on one invocation's path has to pick the same fields, or the same request is attributed |
| 37 | +# differently depending on which component emitted the event. Excludes the batch fields, which |
| 38 | +# describe the work rather than who it belongs to. |
| 39 | +DIMENSION_FIELDS = ( |
| 40 | + "invocation_id", |
| 41 | + "tenant_id", |
| 42 | + "org_id", |
| 43 | + "job_id", |
| 44 | + "workflow_id", |
| 45 | + "attribution_id", |
| 46 | + "dag_node_id", |
| 47 | + "dag_node_type", |
| 48 | + "dag_node_subtype", |
| 49 | + "record_id", |
| 50 | + "attempt", |
| 51 | +) |
| 52 | + |
| 53 | +# Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value. |
| 54 | +_ABSENT = object() |
| 55 | + |
| 56 | + |
| 57 | +class UnsupportedContextVersionError(InvocationSettingsError): |
| 58 | + """The ``invocation_context`` declares a ``schema_version`` this package does not understand. |
| 59 | +
|
| 60 | + A producer upgrade this consumer cannot follow — deployment skew between platform components, |
| 61 | + not a fault in the request. ``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced |
| 62 | + by the platform's own claim pipeline, and a 422 would make an upstream blame classifier pin a |
| 63 | + version-skew failure on the customer. Loud at the first request rather than silently absent |
| 64 | + telemetry dimensions later. |
| 65 | + """ |
| 66 | + |
| 67 | + reason = "unsupported_context_version" |
| 68 | + blame = Blame.CONTENT |
| 69 | + |
| 70 | + |
| 71 | +class InvocationContext(pydantic.BaseModel): |
| 72 | + """Request-scoped identity delivered alongside one claimed unit of work. |
| 73 | +
|
| 74 | + ``extra="allow"`` keeps forward compatibility: fields added by a newer producer survive round |
| 75 | + trips and stay reachable via ``model_extra`` instead of being silently dropped. |
| 76 | + """ |
| 77 | + |
| 78 | + model_config = pydantic.ConfigDict(extra="allow") |
| 79 | + |
| 80 | + schema_version: str = "1" |
| 81 | + |
| 82 | + invocation_id: str | None = None |
| 83 | + job_id: str | None = None |
| 84 | + workflow_id: str | None = None |
| 85 | + attribution_id: str | None = None |
| 86 | + tenant_id: str | None = None |
| 87 | + org_id: str | None = None |
| 88 | + dag_node_id: str | None = None |
| 89 | + dag_node_type: str | None = None |
| 90 | + dag_node_subtype: str | None = None |
| 91 | + record_id: str | None = None |
| 92 | + attempt: int | None = None |
| 93 | + job_created_timestamp: str | None = None |
| 94 | + |
| 95 | + # Added by the controller on the way to the plugin, not by the work API. The batch pair is |
| 96 | + # index-aligned: entry i of `invocation_ids` is the invocation id of record i, or None where |
| 97 | + # that record carried no context. There is deliberately no `work_dir` field: scratch space is |
| 98 | + # the plugin's implementation detail (tempfile / uuid-named paths), not invoke-contract surface. |
| 99 | + record_ids: list[str] | None = None |
| 100 | + invocation_ids: list[str | None] | None = None |
| 101 | + |
| 102 | + @pydantic.field_validator("schema_version") |
| 103 | + @classmethod |
| 104 | + def _known_version(cls, value: str) -> str: |
| 105 | + if value not in SUPPORTED_CONTEXT_VERSIONS: |
| 106 | + raise ValueError( |
| 107 | + f"unsupported invocation_context schema_version {value!r}; " |
| 108 | + f"this package understands {sorted(SUPPORTED_CONTEXT_VERSIONS)}" |
| 109 | + ) |
| 110 | + return value |
| 111 | + |
| 112 | + |
| 113 | +def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None: |
| 114 | + """Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``. |
| 115 | +
|
| 116 | + Returns ``None`` only when the reserved key is **absent** — the transitional signal that the |
| 117 | + caller is an older controller. A present-but-invalid value fails closed rather than degrading |
| 118 | + to "no context", because a context that silently vanishes takes a pod's tenant attribution with |
| 119 | + it. |
| 120 | +
|
| 121 | + A recognizable context carrying an unknown ``schema_version`` raises |
| 122 | + :class:`UnsupportedContextVersionError` so a producer upgrade is loud at the first request |
| 123 | + instead of showing up later as absent telemetry dimensions. |
| 124 | + """ |
| 125 | + raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT) |
| 126 | + if raw is _ABSENT: |
| 127 | + return None |
| 128 | + if isinstance(raw, InvocationContext): |
| 129 | + return raw |
| 130 | + try: |
| 131 | + return InvocationContext.model_validate(raw) |
| 132 | + except pydantic.ValidationError as exc: |
| 133 | + errors = exc.errors() |
| 134 | + if any(error["loc"] == ("schema_version",) for error in errors): |
| 135 | + raise UnsupportedContextVersionError( |
| 136 | + f"unsupported invocation_context schema_version: " |
| 137 | + f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}" |
| 138 | + ) from None |
| 139 | + # `from None` so the pydantic error tree does not cross the domain-error boundary; a count |
| 140 | + # plus the first message is enough signal. Mirrors the envelope extraction. |
| 141 | + raise MalformedEnvelopeError( |
| 142 | + f"invalid invocation_context: {exc.error_count()} validation error(s), " |
| 143 | + f"first: {errors[0]['msg']}" |
| 144 | + ) from None |
| 145 | + |
| 146 | + |
| 147 | +def _reported_version(raw: Any) -> Any: |
| 148 | + """The offending ``schema_version``, for the error message only. Never trusted.""" |
| 149 | + return raw.get("schema_version") if isinstance(raw, Mapping) else None |
| 150 | + |
| 151 | + |
| 152 | +def dimensions(context: InvocationContext | None) -> dict[str, Any]: |
| 153 | + """The context's populated identity facets, ready to bind as telemetry dimensions.""" |
| 154 | + if context is None: |
| 155 | + return {} |
| 156 | + return { |
| 157 | + field: value for field in DIMENSION_FIELDS if (value := getattr(context, field)) is not None |
| 158 | + } |
0 commit comments