0039: Observability — Caller-Supplied invocation_id¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-05-28
- Accepted: 2026-05-28
- Targets: spec/observability/spec.md (§5.1 Invocation span attributes — reframe
openarmature.invocation_idfrom "framework-generated UUIDv4" to "caller-supplied or framework-generated," mirroring §3.1'scorrelation_idpattern; §3.2 distinction table touch; §8.4.1 Trace-level mapping — define the Langfusetrace.idderivation for caller-supplied non-UUID ids); spec/graph-engine/spec.md (§3 clarifying touch —invoke()MAY accept a caller-supplied invocation id, mechanism per-language idiomatic, mirroring 0034'smetadatatouch); conformance fixtures under spec/observability/conformance/ and spec/pipeline-utilities/conformance/. - Related: 0007 (observability OTel span mapping — defined
openarmature.invocation_id), 0031 (observability Langfuse mapping — defined §8.4.1trace.id= invocation_id), 0034 (caller-supplied invocation metadata — established the per-languageinvoke()caller-surface pattern this proposal mirrors), 0008/0009 (checkpointing / per-instance fan-out resume — the resume-mints-fresh-id interaction this proposal preserves) - Supersedes:
Summary¶
Let a caller pass its own invocation_id into invoke(), instead
of always taking OA's framework-minted UUIDv4. Additive and opt-in:
absent → mint a UUIDv4 as today; supplied → use the caller's value
for the invocation context, checkpoint keys, observer events, and
span attributes.
This mirrors the existing caller-supplied correlation_id (§3.1):
a caller-supplied id MAY be any non-empty URL-safe string, while the
UUIDv4 format mandate applies only to the framework-generated
(absent) case. Because invocation_id is structurally load-bearing
in the Langfuse mapping (§8.4.1 uses it as the Langfuse trace.id,
which must be a valid OTel-style 128-bit id), this proposal also
defines how the Langfuse mapping derives a valid trace.id from a
non-UUID caller value — preserving caller flexibility without
producing broken traces.
The motivating problem is a real ordering race: Checkpointer.save
is synchronous (awaited inside invoke()), while observer dispatch
is queue-mediated and invoke() returns without draining. A
consumer that registers a parent run row via an observer (an async
insert) races the first node's checkpoint write (a synchronous
child insert with a foreign key to that parent's invocation_id).
Child-before-parent is not just possible — it dominates under load.
Letting the caller supply the invocation_id lets it insert the
parent row synchronously before invoke(), eliminating the race.
Motivation¶
invocation_id (observability §5.1) is the framework-generated
UUIDv4 that ties together all spans, checkpoint records, and (in the
Langfuse mapping) the trace of a single invocation. Today the
framework always mints it internally with no hook between minting
and the first node running.
A consumer that wants its own identity space to join on OA's
invocation_id has only bad options:
- Capture it after the fact from inside an observer or node body — but this loses the race described below.
- Run a parallel id space and never join to OA's — losing the ability to correlate the consumer's records with OA's spans / checkpoints / Langfuse traces.
The race (code-verified in the reference implementation):
Checkpointer.saveis synchronous by contract — the engine awaits each save before advancing, insideinvoke().- Observer dispatch is queue-mediated on a separate delivery task;
invoke()returns without draining the queue. - So a consumer that registers the invocation's parent row via an
observer (async insert) races the first node's checkpoint write
(a synchronous child insert carrying a foreign key to the parent
row keyed by
invocation_id). The child insert can — and under load usually does — land before the observer's parent insert.
The first production OA consumer hit exactly this against a
checkpoints.invocation_id → runs.invocation_id foreign key,
observing a child-before-parent rate high enough that they dropped
the constraint to ship. The fix: let the consumer mint the id, write
the parent row synchronously before invoke(), and pass the same id
in — the observer-based registration (and its race) goes away.
Precedent. correlation_id is already caller-supplyable (§3.1):
"Accept a caller-supplied ID at invoke time … When the caller
supplies an ID, the framework uses it verbatim. … Caller-supplied
correlation IDs MAY be any non-empty URL-safe string … the format
mandate applies only to the auto-generated case." This proposal
extends the identical opt-in pattern to invocation_id.
Detailed design¶
§5.1 — reframe openarmature.invocation_id¶
§5.1's current text mandates the attribute "MUST be a UUIDv4
(canonical 36-character form)" unconditionally. Reframe to parallel
§3.1's correlation_id treatment:
openarmature.invocation_id— string. A unique identifier for this invocation. Caller-supplied or framework-generated. When the caller supplies an id at invoke time, the framework uses it verbatim; a caller-supplied id MAY be any non-empty URL-safe string. When the caller does not supply one, the framework MUST generate a UUIDv4 (canonical 36-character form). The UUIDv4 format mandate applies only to the framework-generated case, so "you don't supply an invocation id" produces consistent UUIDv4 output across implementations. Backends that derive a fixed-width identifier frominvocation_id(e.g., the Langfusetrace.idper §8.4.1) define their own derivation for non-UUID values.
The §3.2 distinction table row for invocation_id updates its
"Source" cell from "Framework" to "Caller (or framework-generated
when absent)."
graph-engine §3 — invoke() caller surface¶
Add a clarifying paragraph (the same kind of touch 0034 made for the
metadata mapping): invoke() MAY accept a caller-supplied
invocation id alongside the existing correlation_id and metadata
surfaces. The mechanism is per-language idiomatic (a keyword
argument, a field on an invocation-config record, equivalent). When
supplied, it becomes the invocation's invocation_id (§5.1);
when absent, the framework mints a UUIDv4.
§8.4.1 — Langfuse trace.id derivation for non-UUID ids¶
This is the load-bearing addition. §8.4.1 currently maps
openarmature.invocation_id → Langfuse trace.id with "MUST use
the invocation_id verbatim as the Trace ID." Langfuse (OTel-based)
requires trace.id to be a 128-bit value rendered as 32 lowercase
hex characters. A UUIDv4 carries exactly 128 bits, so the verbatim
mapping works by stripping dashes. A non-UUID caller value does not,
and passing it through unchanged produces an invalid (silently
broken) Langfuse trace.
Updated §8.4.1 rule:
- When
invocation_idis a valid UUID:trace.id= the UUID's 32-character lowercase hex form (dashes stripped). Direct lookup byinvocation_idworks (strip dashes to search), as today. - When
invocation_idis not a UUID:trace.id= a deterministic 128-bit derivation of theinvocation_id— the first 16 bytes ofSHA-256(invocation_id), rendered as 32 lowercase hex characters. The rawinvocation_idis ALSO written totrace.metadata.invocation_idso lookup by the caller's original value works via Langfuse metadata filtering. The derivation MUST be deterministic and stable across implementations (SHA-256 of the UTF-8 bytes ofinvocation_id, first 16 bytes, lowercase hex) so a TypeScript and a Python implementation map the same caller id to the same Langfuse trace.
This replaces the v0.10.0 Langfuse adapter's current behavior of
passing non-UUID inputs through unchanged (which fails Langfuse's
trace-id parse). The OTel mapping is unaffected: there
openarmature.invocation_id is a span attribute carrying the raw
value, independent of the OTel span context's own trace id, so any
string value surfaces correctly.
Why allow non-UUID rather than mandate hard-UUID. A hard-UUID
requirement (the simpler alternative) was considered and rejected:
the whole point of caller-supplied invocation_id is to let a
consumer reuse an id from its own system (a run id, a job id), which
may not be a UUID. correlation_id already accepts any URL-safe
string for exactly this reason; constraining invocation_id to
UUIDs only would force consumers to maintain a UUID id-space solely
to satisfy OA, undermining the join-on-our-id use case the proposal
exists to serve. The deterministic Langfuse derivation closes the
one place a non-UUID value would otherwise break, so flexibility
costs only a small, well-defined mapping rule.
Resume interaction¶
Resume (per checkpointing / per-instance fan-out resume) mints a
fresh invocation_id regardless: each attempt is its own
invocation in the §5.1 sense. A caller-supplied invocation_id
applies ONLY to the fresh-invocation path; on a resume call the
framework mints a fresh id and ignores any caller-supplied
invocation_id (a caller wanting to correlate resume attempts uses
correlation_id, which is stable across attempts by design). This
preserves the existing resume semantics unchanged.
Spec-text changes (summary)¶
- observability §5.1 — reframe
openarmature.invocation_id(caller-supplied or framework-generated; UUIDv4 mandate applies to the framework-generated case only). - observability §3.2 — distinction table "Source" cell for
invocation_id. - observability §8.4.1 — Langfuse
trace.idderivation: UUID → hex (verbatim, as today); non-UUID → deterministic SHA-256-based 128-bit hex + raw id preserved intrace.metadata.invocation_id. - graph-engine §3 — clarifying paragraph:
invoke()MAY accept a caller-supplied invocation id (per-language idiomatic).
No changes to §5.2-§5.6, the OTel mapping (§4 / §7 — invocation_id
is an attribute there, format-agnostic), §6, or other §-sections.
Conformance fixtures¶
observability/conformance/NNN-caller-invocation-id-uuid— caller supplies a valid UUIDinvocation_id; it surfaces verbatim onopenarmature.invocation_id; the Langfuse mapping setstrace.idto its 32-hex form; round-trips on the invocation span.observability/conformance/NNN-caller-invocation-id-non-uuid— caller supplies a non-UUID string (e.g.,"run_abc123"); it surfaces verbatim onopenarmature.invocation_id; the Langfuse mapping setstrace.idto the deterministic SHA-256-derived 32-hex value and writes the raw id totrace.metadata.invocation_id.pipeline-utilities/conformance/NNN-resume-mints-fresh-invocation-id— a resume call supplying a callerinvocation_idmints a fresh id for the resumed attempt and does NOT adopt the caller-supplied value (caller-supplied applies to the fresh-invocation path only).
(Exact fixture numbers assigned at accept time against the then-current conformance sequences.)
Versioning¶
MINOR bump. Additive: a new opt-in caller surface, a §5.1
reframe that relaxes (does not tighten) the format constraint, a new
§8.4.1 derivation branch for a value shape that could not previously
occur, and a graph-engine §3 clarifying touch. No breaking changes
for callers that don't supply an invocation_id (unchanged
UUIDv4-minting behavior).
Backwards compatibility¶
- Callers not supplying
invocation_id: no change — framework mints a UUIDv4; Langfusetrace.idderivation is the UUID-to-hex path, identical to today. - The v0.10.0 Langfuse adapter currently passes non-UUID inputs through unchanged (producing a broken trace). Implementing this proposal changes that path to the SHA-256 derivation — a behavior change only for the non-UUID case, which is newly reachable by this proposal. No existing (UUID-only) behavior changes.
Out of scope¶
- Caller-supplied ids for nested constructs (subgraph / fan-out instance / detached trace ids). This proposal covers the outermost invocation id only; nested ids remain framework-derived.
- Changing the OTel span-context trace id. The OTel mapping's
trace id is managed by the OTel SDK;
openarmature.invocation_idremains a span attribute. This proposal does not make the OTel trace id caller-settable. - Resume adopting a caller-supplied id. Explicitly preserved as fresh-mint (see Resume interaction).
Open questions¶
None blocking at draft time. The validation strictness (any URL-safe string, not hard-UUID) and the Langfuse non-UUID derivation (SHA-256 → 128-bit hex + raw-id-in-metadata) are resolved above. The deterministic-derivation algorithm is pinned (SHA-256, first 16 bytes, lowercase hex) so cross-implementation Langfuse trace ids agree for a given non-UUID caller id.