Skip to content

Commit 93c3f54

Browse files
committed
feat(etl-uvicorn): own the invocation-context model and the blame status spelling
unstructured_platform_plugins.invocation_context holds the /invoke identity contract: InvocationContext, extract_context, dimensions, the reserved context key, the dimension fields, the supported versions, and UnsupportedContextVersionError. The context is protocol identity - no crypto, no secrets - so it ships with the plugin protocol; its errors subclass the shared InvocationSettingsError taxonomy so hosts classify context failures with the same reason/blame machinery as settings failures. http_status_for - the HTTP spelling of the library's normative blame -> status rule - lives with the middleware that emits the responses.
1 parent 2d66570 commit 93c3f54

4 files changed

Lines changed: 353 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22

33
* **This package now owns the `/invoke` transport for the reserved fields.**
44
`unstructured_platform_plugins.invocation_settings` holds the ASGI middleware, the `/metadata`
5-
capability route, and the request-scoped binding. It sits on `utic-invocation-settings >=0.4.0`,
6-
which owns the *contract* — which keys carry settings, how a sealed envelope is told from
7-
plaintext, and what an absent field is allowed to mean. That split is deliberate: the absence
8-
rule is a security decision and belongs next to the crypto it governs, while body buffering and
9-
route registration belong here, where a web framework is already a dependency. Nothing about the
10-
wire format is decided in this repository.
5+
capability route, the request-scoped binding, and `http_status_for` — the HTTP spelling of the
6+
library's normative `blame` → status rule. It sits on `utic-invocation-settings >=0.4.0`,
7+
which owns the *settings contract* — which key carries settings, how a sealed envelope is told
8+
from plaintext, and what an absent field is allowed to mean. That split is deliberate: the
9+
absence rule is a security decision and belongs next to the crypto it governs, while body
10+
buffering and route registration belong here, where a web framework is already a dependency.
11+
Nothing about the sealed-settings wire format is decided in this repository.
12+
* **This package now owns the `invocation_context` identity model.**
13+
`unstructured_platform_plugins.invocation_context` holds `InvocationContext`,
14+
`extract_context`, `dimensions`, `RESERVED_CONTEXT_KEY`, `DIMENSION_FIELDS`,
15+
`SUPPORTED_CONTEXT_VERSIONS` and `UnsupportedContextVersionError`. The context is `/invoke`
16+
protocol identity — no crypto, no secrets — so it lives with the plugin protocol. Its errors
17+
subclass the shared `InvocationSettingsError` taxonomy, so hosts classify context failures with
18+
the same `reason`/`blame` machinery as settings failures. This module is the public home for
19+
the surface `utic-invocation-settings 0.2.x` carried and its `0.3.0` removed.
1120
* **Every wrapped app installs it at construction.** The reserved `invocation_settings` /
1221
`invocation_context` fields are handled outside the generated handler schema, a sealed
1322
`dag_node_settings` member is opened with this pod's mounted workload key, and the resolved
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from utic_invocation_settings import (
5+
Blame,
6+
DecryptionError,
7+
IdentityNotMountedError,
8+
KeyNotFoundError,
9+
MalformedDagNodeSettingsError,
10+
MalformedEnvelopeError,
11+
SealedDagNodeSettingsRequiredError,
12+
)
13+
14+
from unstructured_platform_plugins.invocation_context import (
15+
RESERVED_CONTEXT_KEY,
16+
InvocationContext,
17+
UnsupportedContextVersionError,
18+
dimensions,
19+
extract_context,
20+
)
21+
from unstructured_platform_plugins.invocation_settings import http_status_for
22+
23+
VALID = {
24+
"schema_version": "1",
25+
"invocation_id": "inv-1",
26+
"job_id": "job-1",
27+
"tenant_id": "tenant-1",
28+
"dag_node_id": "node-1",
29+
"dag_node_type": "chunker",
30+
"record_id": "rec-1",
31+
"attempt": 2,
32+
}
33+
34+
35+
def test_extracts_identity_fields_from_body():
36+
context = extract_context({"file_data": {"path": "x"}, RESERVED_CONTEXT_KEY: VALID})
37+
assert context is not None
38+
assert context.tenant_id == "tenant-1"
39+
assert context.attempt == 2
40+
41+
42+
def test_absent_key_returns_none():
43+
assert extract_context({"file_data": {"path": "x"}}) is None
44+
45+
46+
def test_accepts_already_parsed():
47+
context = InvocationContext(**VALID)
48+
assert extract_context({RESERVED_CONTEXT_KEY: context}) is context
49+
50+
51+
def test_present_but_null_fails_closed():
52+
# Same rule as the envelope: a context that silently vanishes takes tenant attribution with it.
53+
with pytest.raises(MalformedEnvelopeError):
54+
extract_context({RESERVED_CONTEXT_KEY: None})
55+
56+
57+
def test_present_but_not_an_object_fails_closed():
58+
with pytest.raises(MalformedEnvelopeError):
59+
extract_context({RESERVED_CONTEXT_KEY: "tenant-1"})
60+
61+
62+
def test_unknown_schema_version_is_rejected_by_its_own_error():
63+
with pytest.raises(UnsupportedContextVersionError) as exc:
64+
extract_context({RESERVED_CONTEXT_KEY: {**VALID, "schema_version": "2"}})
65+
assert "'2'" in str(exc.value)
66+
67+
68+
def test_partial_context_is_accepted():
69+
# A producer that populates only some identity facets degrades to less telemetry, not a
70+
# failed invoke.
71+
context = extract_context({RESERVED_CONTEXT_KEY: {"schema_version": "1", "job_id": "job-1"}})
72+
assert context is not None
73+
assert context.job_id == "job-1"
74+
assert context.tenant_id is None
75+
76+
77+
def test_unknown_fields_survive_for_forward_compatibility():
78+
context = extract_context({RESERVED_CONTEXT_KEY: {**VALID, "future_field": "keep me"}})
79+
assert context is not None
80+
assert context.model_extra["future_field"] == "keep me"
81+
82+
83+
def test_batch_fields_are_index_aligned():
84+
# The controller emits one invocation id per record, using None where a record carried no
85+
# context, so entry i always describes record i.
86+
context = extract_context(
87+
{
88+
RESERVED_CONTEXT_KEY: {
89+
**VALID,
90+
"record_ids": ["rec-1", "rec-2", "rec-3"],
91+
"invocation_ids": ["inv-1", None, "inv-3"],
92+
}
93+
}
94+
)
95+
assert context is not None
96+
assert len(context.record_ids) == len(context.invocation_ids)
97+
assert dict(zip(context.record_ids, context.invocation_ids))["rec-2"] is None
98+
99+
100+
class TestDimensions:
101+
def test_returns_populated_identity_facets(self):
102+
context = InvocationContext(**VALID)
103+
104+
assert dimensions(context) == {
105+
"invocation_id": "inv-1",
106+
"job_id": "job-1",
107+
"tenant_id": "tenant-1",
108+
"dag_node_id": "node-1",
109+
"dag_node_type": "chunker",
110+
"record_id": "rec-1",
111+
"attempt": 2,
112+
}
113+
114+
def test_excludes_batch_fields(self):
115+
# These describe the work, not who it belongs to, and would blow up dimension cardinality.
116+
context = InvocationContext.model_validate(
117+
{**VALID, "record_ids": ["a"], "invocation_ids": ["b"]}
118+
)
119+
120+
assert not {"record_ids", "invocation_ids"} & set(dimensions(context))
121+
122+
def test_unknown_producer_fields_are_not_promoted_to_dimensions(self):
123+
context = InvocationContext.model_validate({**VALID, "future_field": "value"})
124+
125+
assert "future_field" not in dimensions(context)
126+
127+
def test_absent_context_yields_no_dimensions(self):
128+
assert dimensions(None) == {}
129+
130+
131+
class TestHttpStatusFor:
132+
"""The transport's spelling of the library's normative `blame` -> status rule."""
133+
134+
def test_caller_blame_is_the_only_422(self):
135+
assert http_status_for(MalformedEnvelopeError("x")) == 422
136+
assert MalformedEnvelopeError.blame is Blame.CALLER
137+
138+
@pytest.mark.parametrize(
139+
"error",
140+
[
141+
DecryptionError("x"),
142+
KeyNotFoundError("x"),
143+
IdentityNotMountedError("x"),
144+
SealedDagNodeSettingsRequiredError("x"),
145+
MalformedDagNodeSettingsError("x"),
146+
UnsupportedContextVersionError("x"),
147+
],
148+
)
149+
def test_everything_else_is_5xx(self, error):
150+
assert http_status_for(error) == 500
151+
152+
def test_an_unclassified_exception_is_not_blamed_on_the_caller(self):
153+
assert http_status_for(RuntimeError("boom")) == 500
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
}

unstructured_platform_plugins/invocation_settings.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Transport for the reserved `/invoke` fields: ASGI middleware, `/metadata`, request binding.
22
3-
The *contract* — which keys carry settings, how a sealed envelope is told from plaintext, and what
4-
an absent field is allowed to mean — lives in `utic_invocation_settings.invoke`, next to the crypto
5-
it governs. This module is the other half: getting the payload off the wire and the result to the
6-
handler. It owns no policy; every decision about a payload it delegates.
3+
The *settings contract* — which key carries settings, how a sealed envelope is told from
4+
plaintext, and what an absent field is allowed to mean — lives in
5+
`utic_invocation_settings.invoke`, next to the crypto it governs; every decision about a settings
6+
payload is delegated there. The *identity contract* — the `invocation_context` model — is
7+
`/invoke` protocol rather than settings security and lives in this package's
8+
`invocation_context` module. This module is the delivery mechanism for both: getting the payloads
9+
off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP
10+
statuses.
711
812
The reserved fields are a first-class HTTP contract independent of the generated input schema. They
913
never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler model
@@ -29,18 +33,33 @@
2933
from starlette.types import ASGIApp, Receive, Scope, Send
3034
from utic_invocation_settings import (
3135
INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_CAPABILITY,
32-
RESERVED_CONTEXT_KEY,
3336
RESERVED_ENVELOPE_KEY,
34-
InvocationContext,
37+
Blame,
3538
InvocationSettingsError,
3639
MalformedEnvelopeError,
37-
extract_context,
38-
http_status_for,
3940
resolve_invocation_settings,
4041
)
4142

43+
from unstructured_platform_plugins.invocation_context import (
44+
RESERVED_CONTEXT_KEY,
45+
InvocationContext,
46+
extract_context,
47+
)
48+
4249
logger = logging.getLogger(__name__)
4350

51+
52+
def http_status_for(error: BaseException) -> int:
53+
"""The HTTP status this transport answers for a failed resolution, from ``blame``.
54+
55+
One rule, the one the library README states normatively: ``Blame.CALLER`` -> 422, everything
56+
else -> 500. The line it draws is whether a different request would work. Sealing drift, an
57+
envelope for another recipient and a broken local mount are all 5xx, which keeps a controller's
58+
blame classification off the customer, whose request was fine. Anything that is not a
59+
classified error is a 500: an unclassified failure is not the caller's.
60+
"""
61+
return 422 if getattr(error, "blame", None) is Blame.CALLER else 500
62+
4463
T = TypeVar("T")
4564

4665
_METADATA_PATH = "/metadata"

0 commit comments

Comments
 (0)