Skip to content

Phase 4a: execution context — the context layer - #57

Merged
Wahbeh-Mohammad merged 5 commits into
mainfrom
14-phase-4a-execution-context
Sep 16, 2026
Merged

Wahbeh-Mohammad merged 5 commits into
mainfrom
14-phase-4a-execution-context

Conversation

@Wahbeh-Mohammad

@Wahbeh-Mohammad Wahbeh-Mohammad commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Part of #14. First of three phase 4a PRs — the code. Its tests are #58 and the phase record is #59.

Merge note. This stack was cut from main at 419aace in parallel with phase 4b; 4b (#53–#55) and 4c (#60–#62) merged first, so the three branches were rebased onto their main and re-proven before merging — the record is in the comment below. The numbers in this body are from the tips as reviewed; the rebased tips add one fix commit (the smoke suite split for Metrics/ClassLength) and the regenerated manifest, and every gate is green on them.

What lands

The correlation model in dexpace-core — 30 files, +1,467 / −8 — three immutable context flavours forming a one-way promotion chain, one call-unique key, one bounded process-wide store, and the instrumentation bundle roadmap cross-phase obligation 1 fixes in core for phase 5 to populate. Nothing here talks to a socket; 4a adds no require of any kind beyond require_relative, and the gemspec keeps zero add_dependency lines.

  • Dexpace::Context (the module the three flavours include, #close its one public method) and the chain — DispatchContext → #promote_to_request → RequestContext → #promote_to_exchange → ExchangeContext, which is terminal and owns no method (CTX-1–CTX-3). Every flavour is Data + Model + private_class_method :new + a validating .build; the bundle and the call key travel by reference (CTX-2, asserted with assert_same); a promotion registers the promoted context in its store slot (CTX-17 — construction registers nothing); operation_name is carried and advisory (CTX-16). A pinned call_key or operation_name must be a String — a review round found a Symbol accepted silently and an Integer leaking NoMethodError.
  • Dexpace::CallKey (private_constant) — CTX-4's rendered prefix plus one process-wide monotonic counter shared by all three flavours, so two default-constructed contexts are never == (CTX-5/CTX-6) and two keys minted from the same Bundle::NONE differ (CTX-15). The key participates in value equality, which is what makes CTX-9's trap constructible at all (R2).
  • Dexpace::BoundedMap (private_constant, the one implementation CTX-11, XCUT-14 and phase 6's AUTH-19 share) and Dexpace::ContextStore with .default (assigned at file load, not memoised — a review round found the memoised form survived every suite), #set, #put (reject-on-duplicate, CTX-8, raising ContextConflictError carrying the key), #[], #release (eviction conditional on reference identity, CTX-9, which is what makes #close idempotent without a latch — a frozen Data cannot carry one, P4-4), #size, and MAX_TRACKED_CONTEXTS = 1024 (P4-9, AUTH-19's number). Insert and drain sit in one synchronize, so the drain body runs at most once per insert (R4); oldest-registration-first is the documented CTX-13 victim, and a promotion re-setting a key does not refresh its position.
  • Dexpace::Instrumentation::Bundle — eight stored members plus the derived #valid? (OBS-26's all-zero rule, P4-6) and #remote?, Bundle::NONE, INVALID_SPAN_ID; TraceIdFlavour with NONE/W3C/DATADOG/ALL and .of, whose invalid trace id varies by flavour because Datadog renders decimal (P4-7); NO_SPAN, NO_TRACER and NO_TRACER_FACTORY, three frozen singletons, #tracer in opentelemetry-api 1.11.0's exact five-parameter shape (deprecated_name = nil, deprecated_version = nil, name: nil, version: nil, attributes: nil, all ignored) so a real provider is call-compatible (P4-8; CTX-20, OBS-25). Four Regexp.new(…, timeout:) patterns, lowercase-only, no case fold.
  • Dexpace::ContextConflictError, the fourth error in phase 2's shape, carrying #call_key.
  • The eighth custom cop, Dexpace/NoWeakReferences — CTX-19's prohibition as a lint over gems/*/lib/**/*.rb: ObjectSpace::WeakMap, ObjectSpace::WeakKeyMap, WeakRef and the three require "weakref" spellings, with its cases in a nested class of cops_test.rb (29 rows). It is the eighth, not the plan's seventh — main already had seven — and the tree, CLAUDE.md and quality-gates.md say so.
  • sig/ mirrors lib/ one file per file — twelve mirrors, bounded_map.rbs and call_key.rbs among them with hooks.rbs's comment on why a private_constant still needs one for the strict core target — plus a fourth RBS interface, _ContextHost, the self-type strict Steep needs for Context#close (ledger row P4-60; first numbered P4-40 while the lanes ran in parallel, renumbered on the docs branch because 4b's addendum took P4-40–P4-49 and P4-50–P4-59 are 4c's). YARD 100% (402 methods). The surface manifest regenerated once (515 → 580 rows, all 65 read against the object model, Data readers included, nothing private leaked). The core smoke suite's constant list is on this branch because the code change invalidates it.

Decisions taken in the open, against the plan's text

Seventeen are itemised in the checklist's "Deviations from the plan"; the ones a reader should know: the construction validation is the private Context#validate_context! (the plan's public Context.validate! would have been an NFR-4-locked name the design never lists); BoundedMap.new(cap:) refuses anything but a positive Integer (a negative cap spins the drain); CallKey.mint checks its bundle so .build(bundle: nil) raises SEAM-29's message rather than NoMethodError; Bundle owns its String members and trace_state in #initialize so #with is validated identically, requires span:/tracer_factory:, and freezes INVALID_SPAN_ID explicitly ("0" * 16 is unfrozen even under the magic comment — a test caught it); DATADOG's bound is (1 << 64) - 1 because rbs types Integer#** as Numeric; the plan's Metrics/ParameterLists inline disables were not needed — phase 0 as built already sets CountKeywordArgs: false, citing this phase's own measurement.

Layering

Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 97.61% on 4.0.6 and 97.58% on 3.2.11 (1,154 runs, 0 failures), because the smoke suite and phase 2's suites reach most of the layer. The tests PR takes the same tree to 99.96%.

Verification

  • Independent reviewer, two fix rounds: five round-0 findings (3 should_fix, 2 nits) and one round-1 finding (should_fix), each verified resolved individually on re-review, then a full third pass — approve, 0 blocking / 0 should_fix / 1 nit.
  • Mutations. 18 run red by the implementer; 18, 20 and 24 applied by the three reviewers themselves, every one caught at the final tip on 4.0.6 and 3.2.11 except the plan's documented while→if drain equivalent: #release comparing == instead of equal? (the CTX-9 trap); the drain evicting two per insert, and deleted; #put as a plain overwrite; a promotion rebuilding the bundle (only assert_same catches it); the key derived from the bundle; one counter per flavour; the store's Hash swapped for ObjectSpace::WeakMap (red on 3.2.11 with its floor-specific NoMethodError); the all-zero pair valid; an uppercase span id accepted; .default memoised; a release-then-set promotion; the cop's Include narrowed to core (caught by the gate suite on the tests branch, since CopCase cannot see .rubocop.yml).
  • Every synchronize block read — BoundedMap's insert-and-drain, CallKey's increment, the store's lookup/release: each a hash write or a counter, never across a callback. The 32-thread CTX-8 race and the 16×1000 CTX-7 burst ran 15/15 under timeout with 1 winner / 31 ContextConflictError and 16,000 entries every time; four seeds on two interpreters, identical run counts.
  • By experiment on both interpreters: 1,000 registered contexts survive three GC.starts (CTX-19) and drop to 0 under the WeakMap swap; ObjectSpace::WeakKeyMap undefined on 3.2.11 while the cop still rejects the spelling there; the Fiber[] boundary in a child Fiber, a new ::Thread and an Enumerator's fiber with the guard that Thread.current[:probe] is visible in none; #tracer called five ways with #parameters matching the gem; #with re-validating on all five Data types on 3.2.11 (Data#with skips initialize there; Model#with is what re-validates); CTX-13 with cap 3 through real promotions.

Known follow-ups from the review (not blocking)

  • R2-1 — three "Guards run red" rows in the checklist carry the first build's failure counts, one short of what the same mutation yields since round 1 added the real-promotion CTX-13 case. Docs branch; noted on Phase 4a: execution context — documentation and phase record #59.
  • TraceIdFlavour::DATADOG accepts a leading-zero decimal rendering such as "007"; OBS-27 says "rendered as a decimal string" and does not forbid it.
  • Bundle exposes both the Data reader #remote and the predicate #remote?; both are in the manifest and NFR-4-locked. Hiding the reader was considered and not done, to keep the Data convention uniform.
  • The cop matches the three require "weakref" spellings its comment names and not autoload :WeakRef, "weakref" — harmless, since any later use must name WeakRef, which on_const flags (R0-5, closed by narrowing the comment; widening the matcher is two rows if wanted).
  • Phase 0's plan amendment says the keyword-splat cop carries no ordinal and 4a's is the seventh; every as-built document counts it and calls 4a's the eighth — the documentation half is on phase 10's inbound list (bullet 38).

Phase 4a (issue #14). One module, Dexpace::Context, that the three
flat Data flavours include: CTX-1's one-way chain is DispatchContext
-> RequestContext -> ExchangeContext, with #promote_to_request and
exchange stage terminal by the absence of a third (P4-1). Each
promotion builds the successor through its .build, carries the bundle,
the call key and the store forward by identity, adds exactly one
artefact, and registers the successor with store.set -- the first
store entry a chain ever has, because construction registers nothing
(CTX-2, CTX-3, CTX-17). #close is store.release(self): a frozen Data
cannot carry a latch, and CTX-9's identity-conditional eviction makes
it idempotent through the store instead (P4-4). CTX-16's operation
name is nil or a non-empty frozen String, carried forward unchanged,
advisory only.

Dexpace::CallKey (private_constant) mints CTX-4's key, a frozen
"traceId:spanId:n" with one process-wide counter under one
::Thread::Mutex, so two default-constructed contexts are never ==
and a pinned call_key: is the one way to make them so (CTX-5, CTX-6).
Dexpace::ContextStore HAS the private_constant Dexpace::BoundedMap --
the one bounded-keyed-map implementation XCUT-14 and AUTH-19 will
share, a strong Hash behind one mutex with XCUT-14's drain loop inside
the same synchronize as the insert -- and exposes CTX-8's #set and
after the mutex is released), CTX-18's #[] and the identity-checked
assigned .default. The cap must be a positive Integer, since a
negative one would spin the drain forever.

Dexpace::Instrumentation is roadmap obligation 1 discharged: Bundle,
a frozen Data of eight members with #valid? derived (P4-6) and NONE
carrying OBS-26's sentinels; TraceIdFlavour, a closed set of NONE,
W3C and DATADOG with the trace-id sentinel on the flavour and the
span-id sentinel on Bundle (P4-7); and NO_SPAN, NO_TRACER and
NO_TRACER_FACTORY, three frozen singletons whose classes are private
so phase 5c can widen them without an NFR-4 diff, the factory's
(P4-8). Every sig/ mirror declares every method for the strict core
Steep target, the two private constants included as hooks.rbs is;
_ContextHost is the self-type Context needs (P4-40). The entry file
requires the twelve files in dependency order.
Dexpace/NoWeakReferences is CTX-19 as a lint rule (P4-10): a constant
reference to ObjectSpace::WeakMap or ::WeakKeyMap -- qualified,
cbase-qualified, or bare inside `module ObjectSpace` -- to WeakRef, or
a `require "weakref"` in any form tools/require_scan.rb reads, is an
offense naming CTX-19 and the cap CTX-11 makes the leak backstop. A
source-text cop, because RuboCop parses and never evaluates, so the
WeakKeyMap rows parse at TargetRubyVersion 3.2 where the constant does
not exist. It is the eighth custom cop, not the plan's seventh:
CLAUDE.md, quality-gates.md, phase 2's checklist and cops_test.rb all
count Dexpace/NoKeywordSplat, which phase 0's plan says is uncounted.
Its cases live in a nested class of cops_test.rb under the 100-line
cap, fourteen rejected and fifteen accepted on RuboCop 1.91.0; the
.rubocop.yml entry scopes it to every gem's lib/, since the require
allowlist covers core alone.

The runtime surface manifest of dexpace-core grows from 515 to 580
lines through `rake surface:regenerate`, once, and every one of the
65 added rows was read against the design's object model: the seven
flat constants and the instrumentation subsystem, their .build and
.of and .default singletons, the Data readers of the five value types
(which the shipped walker holds, closing the plan's finding against
phase 0), Bundle's #valid? and #remote?, TraceIdFlavour's two
predicates and four constants, ContextStore's five methods and its
cap, and the three no-op singletons typed by their private classes.
Nothing removed; no private_constant present. Context's construction
validation is a private instance method rather than the plan's public
Context.validate!, so the module's public surface is the one method
the design gives it. The core smoke suite's constant list gains the
seven public constants and asserts BoundedMap and CallKey are as
unreachable as Hooks. Both are files a gate reads at run time, so
they travel with the code.
Review round 0, R0-4 and R0-5. dispatch_context.rbs still named the
plan's public Context.validate!, which the build made the private
instance method #validate_context! (checklist deviation 5); the comment
now names what lib defines. The cop's head comment claimed to match
`require "weakref"` "in the forms tools/require_scan.rb reads", but the
scanner's loader list includes autoload and the cop's matcher does not:
`autoload :WeakRef, "weakref"` produces no offense on its own line. The
comment now states the three require spellings the matcher reads and
why the autoload form needs no row -- it names the constant as a Symbol,
and the reference that later triggers it is the one on_const flags,
verified against a scratch adapter file on 4.0.6.
Context#validate_context! accepted any non-nil object as a pinned
call_key and the two flavours that carry an operation_name checked only
for "": a Symbol passed silently, keying a slot no String lookup could
find, and an Integer escaped as a NoMethodError from #empty? where every
core model raises a field-named InvalidArgumentError. The design says a
given key "must be a non-empty String" and that operation_name is nil or
a non-empty frozen String, and sig/ already types both that way.

Add the "must be a String" guard to #validate_context!, between the
required! check and the empty check, and hoist CTX-16's two-state rule
into one private Context#validate_operation_name! the RequestContext and
ExchangeContext initializers share, so the message form lives in one
place the way Model.required! keeps SEAM-29's. The .build paths pass a
frozen Symbol or Integer through Model.frozen_string untouched, so the
guard in initialize covers .build, #with and both promotions alike.
No public signature changes; context.rbs declares the private helper.
The three phase-4 lanes each added their layer's resolution case to
dexpace_test.rb; alone each kept the class under Metrics/ClassLength's
hundred lines, together they reach 116. The six "requiring dexpace
alone makes the whole <layer> resolve" cases move into a nested
DexpaceTest::Layers, the split every larger suite in this gem already
uses, so the honest RuboCop run is clean again. No case changes.
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 14-phase-4a-execution-context branch from d8cf132 to c098a95 Compare September 16, 2026 20:04
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Rebase-and-reprove record for the phase 4a stack (2026-09-16)

The stack was cut from main at 419aace in parallel with 4b; 4b (#53–#55) and 4c (#60–#62) merged first, so the three branches were rebased onto main at 099928e before merging — git rebase --onto main 419aace for the code branch, then each descendant onto its new parent, conflicts resolved by hand, every commit kept (thirteen on the stack, none squashed or reordered). Rebased tips: code d8cf132 → c098a95, tests 409d2c7 → 5752d86, docs dfde33f → 733de8a.

What conflicted, and how each was resolved

  • gems/dexpace-core/lib/dexpace.rb — three independent phase-4 require blocks landing in one place. All three kept, in sub-phase order (4a, 4b, 4c); the tree loads and every layer's constants resolve.
  • gems/dexpace-core/test/dexpace_test.rb — each lane pinned its layer's constants and added its "requiring dexpace alone makes the whole X resolve" case. All three kept: CONTEXT_LAYER, RECOVERY_LAYER and PIPELINE_LAYER in LAYERS, all three cases present. The merged class then reached 116 lines against Metrics/ClassLength's 100 — invisible to the vacuous nested-worktree rake rubocop, caught by the honest --ignore-parent-exclusion run — so one fix commit on the code branch (c098a95) moves the six per-layer cases into a nested DexpaceTest::Layers, the split every larger suite in the gem already uses. No case changed.
  • test/fixtures/surface/dexpace-core.txt — regenerated on the merged tree, never merged by hand: 665 (4c's main) + 65 (4a's rows) = 730, no row of 4b's or 4c's changed, gates:surface_snapshot green.
  • CLAUDE.md, README.md, docs/README.md, gems/dexpace-core/README.md, docs/sdk-documentation/architecture.md — the built-phases sentences, the file counts and the page lists merged from both sides and re-derived from the tree: phases 0–4c built; one hundred and three lib/dexpace/ files beside version.rb, every one mirrored in sig/, six private_constants without a test/ mirror (hooks.rb, bounded_map.rb, context/call_key.rb, recovery/ownership.rb, pipeline/sync_driver.rb, pipeline/async_driver.rb); eight checklists; eight as-built pages.
  • The roadmap — 4a's status note appended after 4b's and 4c's, with one added paragraph saying its counts are the build's and what they became after the rebase (733de8a). The step-5 correction and the five segmentation-design sentences applied without conflict.

Re-proven on the rebased tips

  • Code tip, 4.0.6: bundle exec rake, all seventeen green — test:gems 1,435 runs / 7,669 assertions, 98.10% (above the floor); 3.2.11 matrix set green, 98.11%.
  • Tests tip, 4.0.6: bundle exec rake green — 1,553 runs / 8,464 assertions, 4,071 / 4,072 lines (99.97%; the one line is phase 2's registry race branch); 3.2.11 matrix set green, 99.97%.
  • Docs tip, 4.0.6: bundle exec rake green (same counts), six manifests matching, YARD 0 undocumented; honest RuboCop 311 files, no offenses; ruby .claude/skills/housekeeping/probe.rb no drift on all eight checks; knowledge verifier OK.
  • Linearity: main ⊂ c098a95 ⊂ 5752d86 ⊂ 733de8a; git log main..docs holds only this phase's thirteen commits.

@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 736cb17 into main Sep 16, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 14-phase-4a-execution-context branch September 16, 2026 20:11
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant