Conversation
ENTERED_SPANS is a RefCell<Vec<SpanCursor>>, and a Vec has a destructor, so the thread-local has one too. with_current_cursor read it with a plain LocalKey::with and a plain borrow. Once the thread-local's destructor has run, `with` panics with "cannot access a TLS value during or after destruction"; a read that reaches it from another thread-local's destructor during thread teardown — tokio, tracing and metrics layers all tear down that way — is therefore a panic inside a destructor, which is a double panic and an abort of the whole process. A plain borrow taken while a writer holds borrow_mut panics the same way. The two existing readers, current_span_path and current_span_lineage, run on boundary and lineage paths that never execute inside a Drop, so the defect has been latent. The hash-seed seam is about to put a reader on Default::default() for every hash collection the service builds, including the ones built while a worker thread tears down, which is what makes it live. It is fixed here on its own terms, as its own commit, so that it can stand — or be cherry-picked — whatever happens to the seam: a reader that cannot panic is correct for the existing callers too. Both accesses are now fallible, and both failures answer "no span entered", which every caller already handles for the ordinary reason. The door list in tests/span_cursor_invariant.rs is unchanged: with_current_cursor is still the only read path. The test registers a thread-local bomb before the cursor stack is initialised, so the bomb's destructor runs after the stack's and reads the cursor from inside it. The discriminating mutation is `try_with` back to `with`, and its signature is a SIGABRT of the test binary rather than a failed test — verified before this commit — which is why the test's doc says so: a green suite alone cannot tell the two apart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
The seam memoized a correlation's key pair in a process-wide `HashMap<String, Arc<OnceLock<HashKeys>>>` keyed by correlation id, filled on first use and evicted from `on_close` when the owning span closed. That is the shape #100 removed for the fork counters, and it was wrong here for the same reason: per-correlation state with a home of its own needs its own eviction clock, and every clock on offer is wrong for some path that carries a correlation past the response. The cell now rides on `SpanContext`. The span that brings a correlation its parent does not already carry mints it; every span created beneath clones the handle in `on_new_span`, so a `spawn_fork` tail running under its `fork_span()` child holds the same cell its request did, and the cell is dropped with the last span that holds it. Nothing to evict, no registry, and no lock on the hot path: the read is the fallible cursor read plus an `OnceLock::get`, and the only allocation is the cell itself, made once per request when its span is created rather than when its first collection is. Three consequences are deliberate, and each has a test: - A correlation entered into deja-context WITHOUT a span — a bare `deja_context::enter` — is not seeded. There is no span to hold a cell, and giving it one would be the second home again. The std arm is the answer. - A sampled-out request is not seeded. The cursor carries the ENGAGED correlation, the same expression `on_enter` engages deja-context with, so a `Skip` decision means no cell, no draw, and no boundary dispatch on a request that opted out of every boundary. - An inner span that re-stamps the same `request_id` inherits its parent's cell rather than minting a second one: one request, one pair, one event. The draw runs with the cursor released. `draw_and_record` stamps its event with `current_span_path()` through the same cursor the seam reads its cell from; taken under that read's borrow it would answer "no span", and the event would go out without the span-path address the rest of the request is keyed by. The one-event integration test asserts the recorded path. Every seam test now runs under a real `DejaCorrelationLayer` span, because that is the only way a correlation gets a cell. The multi-thread case enters the span for the first time on a worker thread, which is where a per-thread or per-enter cell would show; the fork-tail test's vacuity guard is the tail's own span path, since there is no eviction left to observe. The TLS-teardown test is kept and now reaches the cursor door through `Default::default()`, the way production does. `deja_context::with_current_correlation_id` stays: `current_correlation_id` goes through it, and a read that cannot panic during thread teardown is right for that caller on its own. Its doc no longer cites this seam as the reason it exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
…he event The seam took the std arm for a request the ingress sampled out — in the registry draft because it read the ENGAGED deja-context correlation, which `on_enter` leaves empty under a `Skip` decision; in the span move because the cursor carried that same engaged value. That inverted a requirement written down before either draft: recorded and unrecorded traffic run ONE hashing regime, and only the event is gated on the sampler's decision. The requirement is the replay premise. A recording stands for the production request it was taken from; if the ~2% that is sampled hashes under one regime (one pair per request) and the rest under another (one pair per collection), an order-dependent behaviour anywhere in the service — two maps over the same keys agreeing on an order, say — shows on one side and not the other, and the tape is evidence about a regime only recorded requests see. Recording is meant to be invisible; a regime split is the recorder changing what it observes. What it costs: std's per-collection key independence (rust#36481) is now given up inside every request rather than inside the sampled ones. The pair is still drawn fresh per request from std's own entropy and is not observable before the request runs, so there is nothing to precompute collisions against; the residual is a request that leaks an order and then accepts more input, which is not a shape this service has. Weighed against a recording that no longer witnesses production, the regime wins. So the cursor carries the raw correlation beside the cell, and every correlation draws through the same `hash_seed` boundary. The seam has no gate of its own: the recorder's capture verdict already answers no-op for a correlation the sampler skipped, before any sequence or occurrence is allocated (the A2 inertness rule in `next_callsite_occurrence`), so a sampled-out request's draw leaves nothing on the tape and nothing in the hook. One gate, the one every other boundary goes through, decides for this one too. An earlier cut of this commit bypassed `dispatch` for the sampled-out case on the belief that the hook would otherwise allocate an occurrence; it would not, and a second gate whose only effect is unobservable is exactly the kind of guarantee that later reads as tested when it never was. Tests: the sampled-out case is asserted on both halves — seeded (unit) and nothing on the tape under its correlation (integration, with the seeded arm as the vacuity precondition; its killer is the recorder's gate, not code in this seam). A nested span carrying a DIFFERENT `request_id` than its parent is pinned to mint its own cell and to leave the parent's intact: the edge between "inherits" and "mints" that a later refactor of `on_new_span`'s match would get wrong. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
The review of #115 asked what the seam does when a correlation is current in a process that is not recording. The answer is structural and was not written anywhere: the cell lives on `SpanContext`, only the correlation layer creates one, and the host installs that layer only while recording or replaying. A correlation made current by any other door has no span context and takes the std arm, which the no-span test already asserts. There is no third state, so this is a sentence in the module doc rather than a new case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtydCoMHX5v44cdUN3ytek
|
Superseded by #135, which is stacked on #133 (the synthesis seam) and #134 ( What #135 keeps from here, because it was right and well-argued:
What it drops, and why: the The trade is honest rather than free — explicit touches the same number of sites, just more invasively, and needs zero machinery in exchange. One thing that did not need to wait: the |
Review status: reviewed at
1c2a4c65— mutations re-run, no defect found, no open asks. The head has since moved by one documentation-only commit (18e7811, a paragraph in thehash_seedmodule doc; no code); the reviewed code is1c2a4c65. Stacked on #111; retarget tomainwhen #111 merges.Read this first
Three commits on top of #111, each standing alone:
c679d8dd8c75baSpanContext1c2a4c6What production says this is for
Same-image self-replay of 2026.09.07.0 against its own 42 sealed tapes
(evidence with verbatim rows:
deja-handovers/results/replay-2026-09-07/divergence-rows.md): 353correlations driven, 296 matched cleanly, 12,725 side-effect calls matched.
The control first. The
idboundary matched 777 times and diverged 0;grpc23/0;imc2720/2;superposition1636/2. Seamed generation is exactwhere it exists, so what remains is not a scoring artefact: the entropy
divergences that are left are coverage (unseamed generators), and the ordering
class below is real nondeterminism.
The largest divergence class is one shape, 21 identity skews (17 redis, 4
db). In one correlation the request considers the same four (connector,
wallet) pairs in both runs and visits them rotated:
[A, B, C, D]recorded,[C, D, A, B]observed — same set, same members,nothing added or dropped. So at the same call site, same rank and same
position the rollout-config key that is looked up names a different pair —
— and everything downstream follows: the redis reply that was a value is
Null,the miss falls to the DB, and calls that happened in one run do not happen in
the other. It presents as identity skew rather than a value divergence because
each call is individually correct; they are just not the same calls.
What it is not: the rollout precedence walk (
build_rollout_keys_by_precedence,unified_connector_service.rs:1247, returns a fixed four-elementVec); aSuperposition filter (that path reads the DB configs table and makes no
Superposition call); an
FxHashMap(constant seed); a membership test. Thedivergence is upstream of the rollout lookup, in whatever orders the pairs that
populate
router_data.connector, and that site is still being run down. A setof members visited in a different order with nothing added or dropped is what
a collection walked under different hash keys looks like, and that is the class
#111 exists for: the seam makes the order reproducible, this stack makes it
hold for the whole life of a request, and the same-image replay after it
deploys is the test — this class should go to zero, or the mechanism is
something else and the rows will say so.
c679d8d— the cursor read cannot panicENTERED_SPANSis aRefCell<Vec<_>>, so the thread-local has a destructor,and
LocalKey::withpanics once it has run. A read reached from anotherthread-local's destructor during teardown — tokio, tracing and metrics layers
all tear down that way — is a panic inside a destructor: a double panic, and an
abort of the process. The two existing readers never ran inside a
Drop, sothe defect was latent; the seam puts a reader on
Default::default()for everyhash collection, including the ones built while a worker thread tears down.
Both accesses are now fallible and both failures answer "no span entered",
which every caller already handles. The door list in
tests/span_cursor_invariant.rsis unchanged.The proof is not a red test. Revert
try_withtowithand the new testdoes not fail — the test binary aborts (
fatal runtime error: thread local panicked on drop, aborting, signal 6). The test's doc says so, because a greensuite cannot tell the two apart.
d8c75ba— the cell rides on the span#111 memoized a correlation's key pair in a process-wide
HashMap<String, Arc<OnceLock<HashKeys>>>, filled on first use and evicted fromon_close. That is the shape #100 removed for the fork counters, and it iswrong here for the same reason: per-correlation state with a home of its own
needs its own eviction clock, and every clock on offer is wrong for some path
that carries a correlation past the response.
Now
SpanContextcarrieshash_keys: Option<Arc<OnceLock<HashKeys>>>, set inon_new_spanby a three-arm match: a span whoserequest_idmatches itsparent's correlation inherits the parent's handle (one request, not two); a span
with its own
request_idotherwise mints a fresh cell; any other span inherits.Every span beneath the owner holds the same handle, so a
spawn_forktailrunning under its
fork_span()child reads the cell its request did, and thecell is dropped with the last span that holds it. Nothing to evict, no
registry, no lock on the hot path: the read is the fallible cursor read plus an
OnceLock::get, and the only allocation is the cell itself, made once perrequest when its span is created.
A correlation entered into deja-context without a span (
deja_context::enteralone) is deliberately not seeded — there is no span to hold a cell, and giving
it one would be the second home again. Hyperswitch has no installer of a
correlation outside the span layer, so this costs nothing real; the negative
test is the statement of the choice.
1c2a4c6— one hashing regime, sampled or notBoth #111 and the first cut of this stack left a request the ingress sampled
out on the std hasher: #111 read the engaged deja-context id, which
on_enterleaves empty under a
Skipdecision, and the span move carried the same value.That inverted a requirement written before either: recorded and unrecorded
traffic run one hashing regime, and only the event is gated on the decision.
The requirement is the replay premise. A recording stands for the production
request it was taken from; if the sampled few percent hash under one regime
(one pair per request) and the rest under another (one pair per collection), an
order-dependent behaviour anywhere in the service shows on one side and not the
other, and the tape is evidence about a regime only recorded requests see.
Recording is meant to be invisible; a regime split is the recorder changing
what it observes.
Cost, stated: std's per-collection key independence (rust#36481) is given
up inside every request rather than inside the sampled ones. The pair is still
drawn fresh per request from std's own entropy and is not observable before the
request runs, so there is nothing to precompute collisions against; the
residual is a request that leaks an order and then accepts more input, which is
not a shape this service has. This is a judgement, isolated in one commit so it
can be reversed in one.
The seam has no gate of its own. Every correlation draws through the same
hash_seedboundary, and the recorder's capture verdict — the one every otherboundary already goes through — answers no-op for a skipped correlation before
any sequence or occurrence is allocated, so a sampled-out request's draw leaves
nothing on the tape and nothing in the hook. An earlier cut bypassed
dispatchfor that case on the belief that the hook would otherwise allocate an
occurrence; it would not, and a second gate whose only effect is unobservable
is the kind of guarantee that later reads as tested when it never was.
Verification
just verifyexit 0, 818 passed.cargo +1.85.0 checkon deja-runtime,deja-context, deja, deja-derive: clean. Merges clean onto
mainde0a42a.Every seam test now runs under a real
DejaCorrelationLayerspan, because thatis the only way a correlation gets a cell; the multi-thread test enters the span
for the first time on a worker thread and asserts the thread ids differ, which
is where a per-thread or per-enter cell would show.
Mutation matrix, read across the rows:
static OnceLockcloned into every ownertwo_correlations_get_different_keys, plus three off-diagonal (a shared cell is already filled when the next test writes or draws)request_idre-stamped mints instead of inheritingdescendant_spans_share_the_owners_cell, and nothing elserequest_idinherits instead of mintinga_nested_span_with_a_different_request_id_mints_its_own_cell, and nothing elsetwo_correlations_get_different_keys, nested-differentoutside_a_correlation_std_runs_verbatim,a_correlation_entered_without_a_span_is_not_seededRefCellborrows are reentrant, so no behaviour differs; the draw-outside-the-borrow shape is defensive against a hook that enters a span, and is documented as that, not claimed as testedCommit 1's mutation (
try_with→with) is the SIGABRT above.Review notes
a_sampled_out_request_is_seeded_and_records_nothing)asserts the seeded arm first, so its "nothing on the tape" cannot pass
vacuously; its killer is the recorder's gate, not code in this seam.
deja_context::with_current_correlation_id(from Record the hash keys a correlation's collections iterate by #111) stays;current_correlation_idgoes through it and a read that cannot panic duringthread teardown is right for that caller on its own. Its doc no longer cites
the seam.
hash_seedevents on arecording → refuse the run up front and say "re-record"), and the
span_cursor_invariant-style gate against a correlation-keyed static indeja-runtime. Both are follow-ups.