diff --git a/.claude/skills/knowledge-lookup/SKILL.md b/.claude/skills/knowledge-lookup/SKILL.md
index 59d5c91..641aff1 100644
--- a/.claude/skills/knowledge-lookup/SKILL.md
+++ b/.claude/skills/knowledge-lookup/SKILL.md
@@ -205,6 +205,7 @@ gives no hint. Never take the entry count as the prefix's ID count.
| Resilience: retry, redirect and authentication | `--topic retry-and-resilience,redirect-handling,authentication,cancellation-and-timeouts --section rules --brief` and `--prefix RETRY,REDIR,AUTH --section rules --brief` | `--prefix-info RETRY`, `--gaps RETRY,REDIR,AUTH,RECOV` | live |
| Serialization, SSE and pagination | `--topic serde,sse-streaming,pagination --section rules,constraints,conclusions --brief` and `--prefix SERDE,SSE,PAGE --section rules,constraints,conclusions --brief` (a `--section rules` reading alone misses `SERDE-17`, `SERDE-24`, `SERDE-25` and `SERDE-30`, filed under Constraints and Conclusions; phase 7a's finding) | `--prefix-info SERDE`, `--gaps SERDE,SSE,PAGE` | live |
| Transport and async-runtime adapters | `--topic transport-adapter,cancellation-and-timeouts,concurrency-and-async --section rules --brief` and `--prefix TRANSPORT,ASYNC --section rules --brief` | `--prefix-info TRANSPORT`, `--gaps TRANSPORT,ASYNC` | live |
+| Cross-cutting invariants and the quality bar | `--topic cross-cutting-invariants,tooling-and-quality-gates,resource-management --section rules --brief` and `--prefix XCUT,NFR --section rules --brief` | `--prefix-info XCUT`, `--prefix-info NFR`, `--gaps XCUT,NFR` | live |
| Styleguide-vs-design conflicts | `--section conflicts --brief` | resolve via a note under notes/ | live |
A regex with an alternation cannot live in a Markdown cell without an escape that would be copied
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa042cf..799f440 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,9 @@ jobs:
- run: bundle exec rake steep
- run: bundle exec rake test:gates
- run: bundle exec rake gates:serde_boundary
+ - run: bundle exec rake gates:cause_walk
+ - run: bundle exec rake gates:bounded_map
+ - run: bundle exec rake gates:seam_names
- run: bundle exec rake gates:rbs_surface
- run: bundle exec rake gates:sig_diff
- run: bundle exec rake gates:surface_snapshot
diff --git a/CLAUDE.md b/CLAUDE.md
index 0c0168f..af69b34 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,11 +13,12 @@ three-state PATCH — solved exactly once, and it deliberately does not compete
Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements
across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy.
-**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a, 8b and 8c are built — the whole of
+**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a, 8b, 8c and 9 are built — the whole of
phase 6, the whole of phase 7, whose three sub-phases were built concurrently off one base and landed in that
order, 7a last (umbrella #25 closes by hand), and the whole of phase 8, whose three sub-phases were built
8a first, off the same base as phase 7's and reconciled onto the tree that holds all three, then 8b and 8c
-concurrently off the tree that holds 8a, 8c landing second; the domain model, the seam layer, the
+concurrently off the tree that holds 8a, 8c landing second, and phase 9, the audit phase, which ships no
+`lib/` code outside `dexpace-conformance` and repairs nothing it finds; the domain model, the seam layer, the
byte-streaming layer, the body layer, the execution context, the recovery layer, the stage pipeline, the
configuration layer, the tracing and metrics layer, the logging facade with its redaction, the retry layer,
the authentication layer, the redirect layer, the server-sent-events layer, the pagination layer, the
@@ -254,9 +255,11 @@ the synchronous transport is the first thing here that talks to a socket, and th
on the async path — the two meet in `dexpace-async-thread`'s composed suite over a real socket. Both
transports talk to a socket; the async one needs a running reactor on the calling thread and creates none. The workspace root
carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and
-the eighteen blocking gates — phase 0's seventeen
-(`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`) and phase 7b's
-`gates:serde_boundary`.
+the twenty-one blocking gates — phase 0's seventeen
+(`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`), phase 7b's
+`gates:serde_boundary` and phase 9's three repository-wide invariant scans, `gates:cause_walk`,
+`gates:bounded_map` and `gates:seam_names`, over one `RubyVM::AbstractSyntaxTree` walker in
+`tools/ast_scan.rb`.
Beyond that, what exists is the specification, the port design, the process tooling and the register,
`docs/deviations.md`. Ruby **>= 3.2** is the floor (`required_ruby_version` in every gemspec but one, asserted by
`gates:versions`; `dexpace-transport-async_http` alone declares **>= 3.3**, read from `VERSIONS`' per-gem
@@ -299,8 +302,8 @@ makes an older Bundler try to install Bundler 4.
```bash
bundle install
-bundle exec rake # the default task: all eighteen gates, in order (NFR-17)
-bundle exec rake gates:list # the eighteen names, in the order CI and `rake` both use
+bundle exec rake # the default task: all twenty-one gates, in order (NFR-17)
+bundle exec rake gates:list # the twenty-one names, in the order CI and `rake` both use
bundle exec rake rubocop # NFR-7, findings fatal, no autocorrection
bundle exec rake rubocop:fix # safe autocorrections only, never the gate
bundle exec rake cops:test # the custom cops' own suite (.rubocop/test/)
@@ -310,6 +313,9 @@ bundle exec rake test:gates # the repository's gate suites
bundle exec rake gates:gemspec_audit # SEAM-1, NFR-1, NFR-2
bundle exec rake gates:require_allowlist # SEAM-1, SEAM-2: the allowlist and the denylist
bundle exec rake gates:serde_boundary # SSE-37, spec-forced boundary 5: no serde under lib/dexpace/{sse,page}/**
+bundle exec rake gates:cause_walk # XCUT-9: Dexpace.each_cause is the only walk of a cause chain
+bundle exec rake gates:bounded_map # XCUT-14: only Dexpace::BoundedMap holds a caller-keyed map
+bundle exec rake gates:seam_names # SEAM-2: core never names a concrete seam implementation
bundle exec rake gates:clean_bundle # the scratch-Gemfile isolation run, all six gems
bundle exec rake gates:rbs_surface # NFR-11
bundle exec rake gates:sig_diff # NFR-4, RBS half (vacuous until the first v* tag)
@@ -1358,10 +1364,12 @@ probe compares each against the live tree, and a count written anywhere else in
`pool.rb` and `timer.rb`, the last a `private_constant`, every one mirrored in `sig/` and the two public
ones in `test/` (`timer.rb` is proven through `pool_delay_test.rb` and `pool_test.rb`'s source scans) —
and its gemspec declares `dexpace-core` alone, by design: the gem spends none of its `NFR-2` budget;
- `dexpace-conformance`'s holds the phase-8a conformance suite with phase 8c's two groups — twenty-five
- files beside phase 0's `version.rb`, nine of them `private_constant`s, every one mirrored in `sig/` and
- every one but `transport_suite/checks.rb`, `wire_server/recorded_request.rb` and
- `wire_server/request_reader.rb` mirrored in `test/` — and its gemspec declares `dexpace-core` alone, its
+ `dexpace-conformance`'s holds the phase-8a conformance suite with phase 8c's two groups and phase 9's
+ four further suites — **fifty-four** files beside phase 0's `version.rb`, twenty-five of them
+ `private_constant`s, every one mirrored in `sig/` and every public one but `check.rb`, `levels.rb`,
+ the four `*_case.rb` files and `wire_server/recorded_request.rb` mirrored in `test/` (each of those six
+ is driven through the suite it serves, and `levels.rb` through `test/gates/requirement_levels_test.rb`)
+ — and its gemspec declares `dexpace-core` alone, its
`socket` and `tempfile` requires carried by the allowlist's exceptions. Every gem under `gems/` is real:
no phase-0 skeleton remains, and the third-party half of each `NFR-2` budget arrived with the phase that
wrote the code needing it — 7a's, 8a's and 8c's (8b's spends none).
@@ -1371,7 +1379,7 @@ probe compares each against the live tree, and a count written anywhere else in
carry that phase's design, plan and checklist; `phase3/` carries its segmentation design,
`docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories —
`phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design, plan and checklist —
- twenty checklists written so far, each at implementation; `phase4/`
+ twenty-one checklists written so far, each at implementation; `phase4/`
carries its segmentation design,
`docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase
directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds that sub-phase's
@@ -1410,9 +1418,11 @@ probe compares each against the live tree, and a count written anywhere else in
gemspec, version and first release phase 8 owns. Its three sub-phases are independent, so
their order is convenience; one task is phase-level because it lands in `dexpace-core`, which
none of the three ships.
- `phase9/` carries **no segmentation design and no sub-phase** — it holds its design and its
- plan directly, `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`
- and `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md`. The
+ `phase9/` carries **no segmentation design and no sub-phase** — it holds its design, its plan
+ and its checklist directly, `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`,
+ `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md` and
+ `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md`,
+ the last written at implementation on 2026-09-23. The
roadmap's segmentation rule reaches build phases 1 through 8 and leaves phases 9 and 10 to
segment "only if their own design finds it necessary"; phase 9's design finds it does not, at
41 IDs (`XCUT-1`–`24`, `NFR-1`–`17`) against phase 1's 42 unsegmented rows, and one gem rather
@@ -1447,5 +1457,6 @@ probe compares each against the live tree, and a count written anywhere else in
at `0.0.0`.
Every checklist but phase 0's, phase 1's, phase 2's, phase 3a's, phase 3b's, phase 4a's, phase 4b's,
phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's,
- phase 7c's, phase 7a's, phase 8a's, phase 8b's and phase 8c's is still to be written at execution time.
+ phase 7c's, phase 7a's, phase 8a's, phase 8b's, phase 8c's and phase 9's is still to be written at
+ execution time — which leaves phase 10's alone.
- There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05.
diff --git a/README.md b/README.md
index a3f69f2..1fc16a4 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,10 @@ response body, HTTP/2 by ALPN over TLS, and a Ruby floor of 3.3 that this gem al
(`docs/sdk-documentation/transport-async_http.md`) — and `dexpace-conformance` carries the
conformance suite: the assertion protocol, the thirty-four-assertion transport suite with its
vacuous and waived rows, the plaintext wire fixture, the Minitest and RSpec drivers and the two
-observability doubles, proven by both transports as its two drivers
+observability doubles, proven by both transports as its two drivers — and, since phase 9, four
+further suites: twenty-eight assertions over every cross-cutting invariant, eight over the
+packaging requirements, two over any wire codec and seven over an async-runtime adapter, with one
+aggregate report that states what a green run does not prove
(`docs/sdk-documentation/conformance.md`). `dexpace-async-thread` carries the
async-runtime adapter — the first executor on the async path: a fixed-size thread pool over a
bounded queue whose `#post` never blocks, the bridge that makes a blocking transport asynchronous
diff --git a/Rakefile b/Rakefile
index aae4b95..1de1c93 100644
--- a/Rakefile
+++ b/Rakefile
@@ -24,6 +24,9 @@ DEFAULT_GATES = %w[
gates:gemspec_audit
gates:require_allowlist
gates:serde_boundary
+ gates:cause_walk
+ gates:bounded_map
+ gates:seam_names
gates:clean_bundle
gates:rbs_surface
gates:sig_diff
diff --git a/docs/README.md b/docs/README.md
index c51daf2..dcb370e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -153,7 +153,11 @@ transport and the conformance suite are what phase 8a built — the first code o
[`sdk-documentation/async-thread.md`](./sdk-documentation/async-thread.md), because the thread pool
that is the async path's first executor is what phase 8b built, and
[`sdk-documentation/transport-async_http.md`](./sdk-documentation/transport-async_http.md), because
-the asynchronous transport, the suite's second driver, is what phase 8c built.
+the asynchronous transport, the suite's second driver, is what phase 8c built. Phase 9 added no page
+of its own: it ships no gem, and what it built extended two that already existed —
+[`sdk-documentation/conformance.md`](./sdk-documentation/conformance.md) gains a section on its four
+suites and [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md) three rows,
+one per repository-wide invariant scan.
## Keeping this file true
diff --git a/docs/first-release.md b/docs/first-release.md
index ee40537..72f98a4 100644
--- a/docs/first-release.md
+++ b/docs/first-release.md
@@ -7,8 +7,9 @@ the same day, on the same rule — a finding is routed to its owner when it is f
and four of its items came here: two blockers before first publish (documenting the `include Dexpace`
constant shadow, and the `AuthDescriptor` carrier decision), `CFG-20`'s cancel-with-interrupt clause
under the unsatisfied MUSTs, and the `CTX-7`/`CTX-8` drain proof under the post-release triggers. Two
-more left a second home here beside their primary owner: the Minitest 6 trigger, and the red
-`gates:bounded_map` blocker phase 10's repair clears. **Nothing has been published.** Since phase 0 landed
+more left a second home here beside their primary owner: the Minitest 6 trigger, and the
+`gates:bounded_map` blocker — which was filed red and expecting a phase-10 repair, and was closed on
+2026-09-23 by phase 9's own green run against a cap `8c` had already shipped, with no repair owed. **Nothing has been published.** Since phase 0 landed
(2026-09-14) `gems/` holds the six gems, since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain
model, and since phase 2 (2026-09-15) the seam layer as well; there is still no tag, and no version beyond the
`0.0.0` every gem starts at.
@@ -132,8 +133,9 @@ stated in the release notes rather than discovered at `bundle install`.
operation projection carrying a descriptor**. Until that event: either the release notes state that the
tier resolver has no carrier and only the client tier is reachable end to end, or a carrier is built
before the tag
-- [ ] **`gates:bounded_map` green: `dexpace-transport-async_http`'s `Clients` is an uncapped
- per-origin client cache, which `XCUT-14` (MUST) forbids.** Found 2026-09-13 by phase 9's planning, and
+- [x] **`gates:bounded_map` green: `dexpace-transport-async_http`'s `Clients` is an uncapped
+ per-origin client cache, which `XCUT-14` (MUST) forbids.** — **CLOSED 2026-09-23 by phase 9's
+ `gates:bounded_map` run.** Found 2026-09-13 by phase 9's planning, and
the **one true positive** of six `gates:bounded_map` reports over 222 filed Ruby fences at 184 distinct
`gems/*/lib/**/*.rb` paths, measured identically on 3.2.11, 3.3.12, 3.4.10 and 4.0.6. The map is
instance-lived and lives as long as the client, its key space is chosen by caller URLs and by a server's
@@ -152,7 +154,17 @@ stated in the release notes rather than discovered at `bundle install`.
evicted first, every evicted client's pool retired and closed — proven by the three `XCUT-14`
cases in `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/clients_test.rb`;
the gate itself does not exist yet, so the line stays open for phase 9's `gates:bounded_map` run
- and nothing else
+ and nothing else. **Closed 2026-09-23**: phase 9 built the gate and ran it. `gates:bounded_map`
+ is **green over all 307 files in the six gems' `lib/` trees**, with six adjudicated exceptions,
+ none of them this one. `clients.rb` is on the allowlist as a **verified false positive of the
+ scan**, not as a silenced defect — the scan sees a Hash assigned to an instance variable and
+ cannot see a cap, and the reason recorded beside the entry is the reading of the file: `#fetch`
+ inserts with `||=` and calls `#drain` inside the SAME `@mutex.synchronize` as the insert, and
+ `#drain` is a loop, `evicted << @by_key.delete(@by_key.keys.first) while @by_key.size >
+ MAX_ORIGINS`, with closed reactors evicted first, which is `XCUT-14`'s drain clause exactly.
+ The as-built ivar is `@by_key` and not `@by_origin`, which is why a check against the planning
+ note's name would have missed it. `XCUT-14`'s checklist row is ✅ and no phase-10 repair is
+ filed
- [ ] **Before release, `docs/sdk-documentation/` carries one worked end-to-end example** — a
generated-style client over `dexpace-core` + `dexpace-transport-net_http` + `dexpace-serde-json`:
operation descriptor, request assembly, pipeline with an AUTH step, decode, typed error, one
diff --git a/docs/knowledge/notes/cross-cutting-invariants.md b/docs/knowledge/notes/cross-cutting-invariants.md
index 94392bd..bd37058 100644
--- a/docs/knowledge/notes/cross-cutting-invariants.md
+++ b/docs/knowledge/notes/cross-cutting-invariants.md
@@ -9,3 +9,5 @@ that entry's stable key.
review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md` · high · sha:manual-phase9-xcut11-predicate
- **The repository-wide invariant scans are Rake gates over `RubyVM::AbstractSyntaxTree`, not conformance assertions, and a `:CALL`-only scan is wrong.** Relates to cross-cutting-invariants/7e6a9aea (`XCUT-14`'s hard cap and drain-to-cap loop) and, by the same argument, to `XCUT-9`'s single-walk rule and `SEAM-2`'s no-concrete-seam rule — **neither key is backticked, because neither harvested rule is false**; what is added is how each is checked. Four checks handed forward to phase 9 are statements about the **absence of a second implementation** rather than about what one implementation does — `XCUT-9`'s "nothing else walks `#cause`", `XCUT-14`'s "one bounded-map implementation", `SEAM-2`'s negative test over core's tree, and `SSE-37`'s `gates:serde_boundary` `PENDING` list — and none can be a portable conformance assertion, because a scan of `gems/*/lib/` is meaningless in a consumer's process where `gems/` does not exist. They land as Rake gates. The mechanism is `RubyVM::AbstractSyntaxTree` rather than a regex, because `grep '\.cause'` matches a comment, a string, an `# XCUT-9` citation in a test header and the requirement ID itself. Verified on 2026-09-12 on 3.2.11, 3.4.10 and 4.0.6: the module is present on all three and `parse_file` returns a `SCOPE` node on all three. **The PARSER emits no warning of its own under `-w`; a SCANNED FILE's own `-w` diagnostics are a different thing and do reach `Warning.warn`** — which is load-bearing, because phase 0's shared test case overrides `Warning.warn` to raise, so a scanner that parses a file carrying one fails the suite that runs it. An earlier form of this entry said parsing emits no warning full stop, generalising from a measurement taken against a file that carries none; that generalisation is struck. Measured 2026-09-13 on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 against 8a's filed `adapter.rb`, whose `rescue ::StandardError => e` inside `Adapter#dispatch` never reads `e` (8a plan:4858): parsing it emits `assigned but unused variable - e` on all four, and with the gate suite's own `dexpace_test_case` loaded a bare `parse_file` raises `RuntimeError: warning treated as an error (NFR-6)` out of `AstScan.receiver_calls` on all four. **Which suppression works was measured, not assumed:** `Warning[:deprecated] = false` does not reach it, because the warning carries no category; `$VERBOSE = false` silences the `-w` class but leaves the always-on parse warnings firing, and `key :a is duplicated` still raised on all four; only `$VERBOSE = nil` silences both. So `AstScan.parse` is the sole parse entry point in the scanner, opening a `$VERBOSE = nil` window restored in `ensure` so the gate stays armed everywhere else (phase 9 plan:4270-4291, its fixture at plan:138-154 and the test that would error without it at plan:4219-4227, and design Fact 1b). `prism` is absent on the 3.2 floor (`LoadError`) and present at 1.9.0 on 3.4.10 and 1.8.1 on 4.0.6, so the AST route needs no conditional require where Prism would. **Four measured corrections to the obvious implementation, each one a gate that reported clean over a live defect.** (1) A send is **three** node types, not one — `a.cause` is `:CALL`, `b&.cause` is `:QCALL`, a bare `cause` is `:VCALL`, identically on all three — and a `:CALL`-only scanner returned two hits for a fixture containing three `#cause` sends. (2) A **reflective** send bypasses call-syntax scanning entirely: `send(:cause)`, `__send__`, `public_send` and `method(:cause)` took the catch rate from 3 of 7 realistic shapes to 6 of 7, the seventh — `send(variable)` — being statically undecidable. (3) **A Symbol literal is a `:LIT` node on Ruby 3.2.11 and a `:SYM` node on 3.4.10 and 4.0.6**, which is the reverse of the usual direction and is why the reflective scan must name both: naming one gave 6 of 7 on the floor and 2 of 7 on the newer rows, a gate strictest exactly where it runs least — which is why this entry names both node types rather than the one the newest interpreter shows. (4) `Hash` is `:CONST` and `::Hash` is `:COLON3`, so `@h = ::Hash.new` escaped a check naming only the first. **And one clause gains a second line rather than moving — which corrects this entry's own earlier form.** `XCUT-14`'s drain-to-cap clause **is** decidable behaviourally, and phase 9 decides it there: `InvariantSuite#bounded_map_drains` reaches the map's backing store through the driver's `bounded_map_store:` hook, pre-fills it to cap + 5 — the state an overshoot would leave, arranged without a thread — and then performs **one** `set`; 4a's filed drain loop ends at the cap, **8**, and a check-then-evict ends at **13**, identically on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 (phase 9 plan:1670-1689, its failing test at plan:1576-1581 asserting `%i[passed failed]`, and phase 9 design:572 and design:755). **What makes that deterministic is that the observation is an eviction count, not an interleaving** — one insert into an over-cap store either drains back to the cap or evicts exactly once — and the over-cap state is *injected* rather than raced for. The earlier racing attempt is kept here because it is a real finding about **that method** and not about the clause: a check-then-evict map was indistinguishable from a drain loop in 0 of 30 runs on 3.4.10 and 4.0.6 at 16 threads and 0 of 20 at 64 threads by 5000 inserts — because no overshoot ever appeared to observe, which is the structural point `../notes/execution-context.md` already records, that an insert and its drain inside one `synchronize` make an overshoot unreachable rather than merely rare. So racing measures nothing about this clause; **it does not make the clause undecidable, and an earlier form of this entry drew that conclusion and moved the clause to a shape check alone — that inference is struck.** `gates:drain_loop` stays as the **second line**, not the only one: a shape check over `BoundedMap`'s own file asserting the eviction send sits inside a `while`/`until`, with its own stated gap — it proves an eviction send is looped in that file, not that the loop is the only eviction path (phase 9 plan:4564-4574) — and a measured reach of 1 of 3 non-conforming shapes with one false positive and 3 of 4 conforming shapes clean, every miss carried into phase 9 plan, Task 13's gate-miss dispositions (phase 9 design:233). A gate that reports clean while the invariant is broken is worse than no gate, which is why phase 0's failing-fixture discipline applies to these five as much as to its own seventeen.
review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md` · high · sha:manual-phase9-ast-gates
+- **`gates:drain_loop` was not built, and `XCUT-14`'s drain clause ships with the behavioural assertion as its ONLY line.** Corrects this file's own earlier entry (`sha:manual-phase9-ast-gates`), which says "`gates:drain_loop` stays as the **second line**, not the only one". At execution on 2026-09-23 phase 9 built **three** repository gates — `gates:cause_walk`, `gates:bounded_map` and `gates:seam_names` — for twenty-one blocking gates in all, and no fourth. The reason is the one that entry already records and then declines to act on: the shape check reached **1 of 3** non-conforming forms and carried a false positive, which is a gate that reports clean over a live defect for two of three real shapes, and phase 0's failing-fixture discipline is the argument against shipping it rather than for it. What replaces it is not nothing: `InvariantSuite#bounded_map_drains` decides the clause **deterministically** — fill the backing store to cap + 5, perform one `#set`, and a drain loop ends at the cap where a check-then-evict ends at cap + 5 — and that assertion is proven to fail against both non-conforming shapes by mutation (phase 9's checklist, "Guards run red", rows 6 and 7). The residue this leaves is honest and is stated in the assertion's own comment: the assertion decides the clause for **one** map, the one the driver hands it, and nothing scans for a second bounded-map implementation that drains wrongly — `gates:bounded_map`'s single-implementation rule is what bounds that, and it is a scan for a Hash on an instance, not for a drain. Anyone reading the earlier entry for "which gates exist" should read this one instead; the rest of that entry — the node types, the reflective sends, the `:LIT`/`:SYM` split, the `$VERBOSE` window and the four measured corrections — was built as written and stands.
+ review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md` · high · sha:manual-phase9-drain-loop-not-built
diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md
index f2f9ceb..37dc5c6 100644
--- a/docs/sdk-documentation/architecture.md
+++ b/docs/sdk-documentation/architecture.md
@@ -211,8 +211,14 @@ and 15 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-cont
runner and its report with vacuous and waived rows that stay visible, the two thin drivers and the
three keywords that are the asynchronous adapter's contract, the case an assertion receives and
the guard on its transport, the plaintext wire fixture with its fifteen scripts and bounded waits,
-and the two observability doubles. Written against the gem phase 8a shipped and the two groups
-phase 8c appended; derives from `docs/sdk-design-ruby/09-toolchain-and-quality-gates.md` §9.3.
+and the two observability doubles — and, in its last section, **phase 9's four further suites**:
+the twenty-eight-assertion invariant suite over all twenty-four `XCUT` IDs, the packaging suite
+over eight `NFR`s read from published gem metadata, the two portable codec assertions, the seven
+executor ones with `ASYNC-3` written so it fails, the generated level map that makes a MUST-level
+vacuity a report blocker, `XCUT-11`'s structural predicate, and the aggregate whose preamble
+prints what a green run does not prove. Written against the gem phase 8a shipped, the two groups
+phase 8c appended and the four suites phase 9 added; derives from
+`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md` §9.3.
[async-thread.md](./async-thread.md) — the async-runtime adapter: the fixed-size thread pool
over a bounded queue and the one required keyword, `#post` as the executor duck type that never
@@ -226,7 +232,9 @@ not do. Written against the adapter phase 8b shipped; derives from
entries 3, 4 and 5 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`.
[quality-gates.md](./quality-gates.md) — every blocking gate this SDK runs, what each protects,
-and how to run it locally. Written against the build phase 0 shipped; derives from
+and how to run it locally; twenty-one of them, and the table says which fixture turns each red.
+Written against the build phase 0 shipped, extended by phase 7b's `gates:serde_boundary` and by
+phase 9's three repository-wide invariant scans; derives from
`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`.
write-a-transport.md — implementing the `Transport` seam, and proving an implementation against
diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md
index ad147ff..252e0cb 100644
--- a/docs/sdk-documentation/conformance.md
+++ b/docs/sdk-documentation/conformance.md
@@ -329,3 +329,150 @@ TLS, no proxy and no connect-timeout assertion, for the reasons the preamble sta
`Thread#kill`, in the fixture included: every wait is a bounded queue pop or a bounded join. And no suite
beyond the transport one yet: the remaining appendix-B suites, and the drivers' second consumer, are
phase 9's; this gem's gemspec, version and first release are phase 8's.
+
+*(That last sentence was true when phase 8a wrote it. The four suites it points at landed on
+2026-09-23 and the section below is what they are; the rest of this page is unchanged and still
+describes the transport suite alone.)*
+
+---
+
+## Phase 9's four suites: the invariants, the packaging, the codec and the executor
+
+**As built by phase 9, written against source on 2026-09-23. Every example below was run verbatim on
+4.0.6 and 3.2.11 and printed the same on both.** Phase 8a shipped the assertion protocol and one suite;
+phase 9 adds four more, one aggregate over all of them, a generated map of every requirement's normative
+level, and the predicate `XCUT-11` needs. The per-requirement proof is
+`docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md`; the
+61-row map of appendix B is `gems/dexpace-conformance/APPENDIX_B.md`, inside the gem.
+
+`C` is `Dexpace::Conformance` throughout.
+
+```ruby
+[C::InvariantSuite, C::PackagingSuite, C::CodecSuite, C::ExecutorSuite]
+ .map { |s| [s.name.split("::").last, s.assertions.size] }
+# => [["InvariantSuite", 28], ["PackagingSuite", 8], ["CodecSuite", 2], ["ExecutorSuite", 7]]
+
+first = C::InvariantSuite.assertions.first
+[first.ids, first.name]
+# => [["XCUT-15"], "public wire models retain no external-mutable alias"]
+```
+
+- **`InvariantSuite`** — 28 assertions over all twenty-four `XCUT` IDs, the cross-cutting invariants
+ every layer is held to. `XCUT-11`, `XCUT-13`, `XCUT-14` and `XCUT-18` carry two each, because each has
+ two clauses that a single check would let a port satisfy by half.
+- **`PackagingSuite`** — 8 assertions over `NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`, read from
+ **published** `Gem::Specification` metadata rather than from a source gemspec, because that is what a
+ consumer resolves. Phase 0's `gates:gemspec_audit` is the same claim's pre-publication half.
+- **`CodecSuite`** — 2 portable assertions a wire codec must satisfy whatever library it wraps: it never
+ closes a target it was handed (`SEAM-20`, `SERDE-3`), and no library exception type escapes the seam
+ (`SERDE-9`).
+- **`ExecutorSuite`** — 7 assertions over an async-runtime adapter's lifecycle, `SEAM-25`'s shutdown
+ event among them, and `ASYNC-3`, which is written so it genuinely fails.
+
+### Running one, and reading the verdict
+
+Every suite's `.run` takes the factories that suite cannot build itself and answers one `Report`.
+
+```ruby
+module PassThrough
+ def self.dexpace_load(parsed, _ctx) = parsed
+end
+
+report = C::CodecSuite.run(build: -> { Dexpace::Serde::JSON.default }, witness: PassThrough,
+ source: ->(text) { Dexpace::IO::BufferedSource.of_bytes(text.b) },)
+[report.passed?, report.passed.size, report.failures.size]
+# => [true, 2, 0]
+```
+
+A factory left `nil` makes its assertions report `:vacuous` **with a reason**, never `:passed` — which is
+what stops "not supplied" from reading as "conforming". `PackagingSuite`'s `NFR-14` is the same rule
+applied to a declaration rather than a factory: with no `versions:` naming the single source of truth it
+is `:vacuous`, because "nobody told us" is not evidence that one exists.
+
+**Every wait a suite performs carries a bound**, so a subject that never answers fails or vacuates its
+assertion rather than parking the run — an executor that refuses the post, one whose `#post` blocks, a
+shared instance that never returns from `#call`, a connection an adapter never releases. That is the rule
+8a fixed for `TransportSuite` with `await_closed_connection`'s `timeout:`, and it holds across all five.
+
+### A MUST-level vacuity is a report blocker
+
+`Levels` is every requirement ID in appendix C mapped to its RFC 2119 level, generated by
+`tools/requirement_levels.rb` and diffed against appendix C by `test/gates/requirement_levels_test.rb` on
+every run. A gem cannot read `docs/` on a consumer's machine, so the map ships inside it.
+
+```ruby
+[C::Levels.of("XCUT-14"), C::Levels.of("XCUT-12"), C::Levels.must?("NFR-16"), C::Levels.known?("XCUT-99")]
+# => [:must, :should, false, false]
+```
+
+A vacuity on a **MUST** fails the run until an acceptance names that ID **with a citation**. A blank
+citation is refused at construction, and an acceptance must name *every* MUST-level ID the assertion
+carries, so accepting one cannot silence a co-carried second.
+
+```ruby
+vacuous = C::Assertion.build(ids: ["XCUT-14"], name: "a map nobody supplied",
+ body: ->(_) { raise C::Vacuous, "no bounded-map factory supplied" },)
+
+blocked = C::Runner.run([vacuous]) { :no_subject_needed }
+accepted = C::Runner.run([vacuous],
+ accepted_vacuous: { "XCUT-14" => "design §12: no map subject here" },) { :none }
+
+[blocked.passed?, blocked.blocking_vacuities.size, accepted.passed?, accepted.accepted_vacuities.size]
+# => [false, 1, true, 1]
+```
+
+### `SharedInstance` — `XCUT-11`'s structural predicate
+
+The rule is a **disjunction**, and both halves matter. A frozen instance conforms on `frozen?` alone: it
+cannot hold per-call state whatever its instance variables say. An unfrozen one conforms only when every
+instance variable it holds is one the **driver** declared — a mutex with the state it guards, or
+`Closeable`'s latch. The declaration is never read off the audited object: a conforming latch-plus-mutex
+object would fail, because no phase committed to such a method, while the identical per-call-state bug
+would pass by declaring its own ivar exempt.
+
+```ruby
+frozen_with_state = Object.new
+frozen_with_state.instance_variable_set(:@policy, :anything)
+frozen_with_state.freeze
+latched = Object.new
+latched.instance_variable_set(:@lock, ::Thread::Mutex.new)
+latched.instance_variable_set(:@closed, false)
+
+[C::SharedInstance.audit(frozen_with_state), C::SharedInstance.audit(latched, mutable: %i[@lock @closed])]
+# => [nil, nil]
+
+leaky = Object.new
+leaky.instance_variable_set(:@last_request, nil)
+C::SharedInstance.audit(leaky, mutable: %i[@lock @closed])
+# raises C::Failure: "Object is shared across concurrent requests and holds undeclared mutable state"
+```
+
+### `Aggregate` — one verdict over a whole run
+
+```ruby
+merged = C::Aggregate.run([report, accepted])
+[merged.passed?, merged.results.size]
+# => [true, 3]
+```
+
+`Aggregate::PREAMBLE` prints on every render and states what a green run does **not** prove: no TLS and
+no connect-timeout classification, nothing covered "by reference" in `APPENDIX_B.md`, nothing waived or
+vacuous, and no MUST-level vacuity that an acceptance did not cite. It is printed rather than filed
+because a limitation in a document nobody re-reads is a limitation nobody knows about.
+
+### Writing your own driver
+
+Each of this repository's three first-party drivers is one generated test per assertion plus a
+report-level test, and yours can be the same six lines. 8a's `MinitestDriver` is not used by them — it
+builds a `TransportCase` for every assertion, which is right for the transport suite and wrong for these
+four — so a driver for one of phase 9's suites calls `.run` (or drives `suite.assertions` itself) and
+asserts on the `Report`:
+
+- `gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` — `InvariantSuite` against core,
+ with every shared instance core publishes (twenty at this tip) and the six factories the suite
+ cannot build.
+- `gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb` — `CodecSuite`, driven again
+ under every option the adapter accepts.
+- `gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb` — `ExecutorSuite`, with
+ `ASYNC-3` waived by ID **and** a second test that runs it unwaived and requires it to fail, so the
+ waiver cannot outlive the limitation it records.
diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md
index 482c1e3..b67b62d 100644
--- a/docs/sdk-documentation/quality-gates.md
+++ b/docs/sdk-documentation/quality-gates.md
@@ -1,11 +1,12 @@
# Quality gates
Every blocking gate this SDK runs, what each protects, and how to run it locally. As built by
-phase 0 (`docs/work/mvp/phase0/`); the design it implements is
+phase 0 (`docs/work/mvp/phase0/`), extended by phase 7b (`gates:serde_boundary`) and by phase 9
+(`gates:cause_walk`, `gates:bounded_map`, `gates:seam_names`); the design it implements is
`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`, and this page describes the build as it
is rather than as it was planned.
-`bundle exec rake` runs all eighteen, in the order below, and stops at the first failure. Each is
+`bundle exec rake` runs all twenty-one, in the order below, and stops at the first failure. Each is
a separately invocable task, because a gate you cannot run alone is a gate you debug by running
everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T` describes them.
@@ -20,19 +21,22 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T
| 7 | `gates:gemspec_audit` | `SEAM-1`, `NFR-1`, `NFR-2`: core declares nothing; an adapter declares `dexpace-core` at `~> MAJOR.MINOR` plus at most one gem; every floor is `>= 3.2` | a core dependency, a second third-party gem, a stale core constraint, a wrong floor, a gemspec that runs a subprocess (`git ls-files`) or `require_relative`s a helper that does, a gemspec that does not load (named, not a `NoMethodError` on nil) |
| 8 | `gates:require_allowlist` | `SEAM-1`, `SEAM-2`, `NFR-1`: every `require` and `autoload` in every gem — parsed, so `require("x")`, `Kernel.require "x"` and a require after a `;` are all seen — is allowlisted, `dexpace` or under `dexpace/`, or the gem's one declared dependency; every `require_relative` stays inside the gem's `lib/`; a denylist names `json`, the transport names and `timeout` with the requirement that forbids each — `socket` scoped to core and the transport adapters, so the conformance gem's wire server may have it, the rest denied to every gem; a feature that is not a string literal is refused as unreadable | `base64`, `logger`, `tsort`, `json`, `timeout`, `net/http` in any gem, `socket` in a bound gem, an escaping `require_relative`, `require("json")`, `Kernel.require`, `autoload :JSON, "json"`, `require name` |
| 9 | `gates:serde_boundary` | `SSE-37`, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE layer and its pagination layer -- `lib/dexpace/sse.rb`, `lib/dexpace/sse/**`, `lib/dexpace/page.rb`, `lib/dexpace/page/**` and their `sig/` mirrors -- hold no serialization dependency, checked by a PARSED scan (prism for every `require`/`require_relative`/`autoload` spelling and every constant read or path, the RBS lexer for every type name), so the YARD may say the words the requirement asks for; every guarded glob must match at least one file, and a path whose files are not on the base yet sits on a printed `PENDING` list until they land, when its rows move to `GUARDED` -- the pagination layer's did on 2026-09-20, when 7c was reconciled onto the tree that holds the gate, and the list has been empty since (added by phase 7b) | `require "json"`, `require_relative "../serde/json"`, `Dexpace::Serde`, a bare `Serde` or `JSON`, `::JSON`, `self::JSON`, a serde type in a guarded `.rbs`, a guarded file that does not parse, a feature that is not a string literal, a `GUARDED` glob matching nothing |
-| 10 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 |
-| 11 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve |
-| 12 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) |
-| 13 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, a `private_constant` excluded, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act |
-| 14 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees |
-| 15 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load |
-| 16 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs |
-| 17 | `yard` | styleguide 14.1: every public object documented | `yard stats --list-undoc` below 100% |
-| 18 | `bundler_audit` | a known CVE in the resolved bundle | `bundler-audit check --update` |
+| 10 | `gates:cause_walk` | `XCUT-9`: `Dexpace.each_cause` is the ONLY walk of an error's cause chain, checked by a parsed scan over every gem's `lib/`. A send is three node types — `a.cause` is `:CALL`, `a&.cause` is `:QCALL`, a bare `cause` is `:VCALL` — and a reflective `send`/`__send__`/`public_send`/`method` naming `:cause` is seen too, by Symbol AND by String; a Symbol literal is `:LIT` on 3.2 and 3.3 and `:SYM` on 3.4 and 4.0, so both are named. A send carrying an ARGUMENT is not a walk, which is what keeps 5b's `Event#cause(error)` builder out. Allowlist: `each_cause.rb` itself (added by phase 9) | `error.cause` anywhere else, `e&.cause`, `errors.map(&:cause)`, `e.send(:cause)`, `e.public_send("cause")` |
+| 11 | `gates:bounded_map` | `XCUT-14`: only `Dexpace::BoundedMap` holds a map whose key space a caller or a server chooses, checked as a parsed scan for a `Hash` assigned to an instance variable — a literal, `Hash.new`, `::Hash.new`, a chained call on a literal and an `||=` are all seen. The allowlist is a path ⇒ REASON Hash and each of its six entries carries the adjudication that put it there, because an allowlist whose entries carry no argument is a list of things somebody once silenced; a test asserts every entry names a live file the scan still reports, so a stale entry cannot silently re-open a hole (added by phase 9) | `@by_origin = {}` in a new file, `@cache = Hash.new`, `@seen ||= {}` |
+| 12 | `gates:seam_names` | `SEAM-2`: core never names a concrete seam implementation, matched by the LEAF namespace each adapter gem owns — `Serde::JSON`, `Transport::NetHTTP`, `Transport::AsyncHTTP`, `Async::Thread` — anywhere in a constant path or a String or Symbol literal, so `const_get("Dexpace::Serde::JSON")` is caught beside the bare constant. A fixed list of fully qualified names caught 0 of 4 realistic shapes and matching any path under a seam namespace flagged 31 conforming references, so the rule is the leaf pair; a later adapter gem adds its leaf here or is invisible, which is the gate's stated gap (added by phase 9) | `Dexpace::Serde::JSON` in core, `Transport::NetHTTP`, `const_get(:"Dexpace::Async::Thread")` |
+| 13 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 |
+| 14 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve |
+| 15 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) |
+| 16 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, a `private_constant` excluded, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act |
+| 17 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees |
+| 18 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load |
+| 19 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs |
+| 20 | `yard` | styleguide 14.1: every public object documented | `yard stats --list-undoc` below 100% |
+| 21 | `bundler_audit` | a known CVE in the resolved bundle | `bundler-audit check --update` |
## Where they run
-`.github/workflows/ci.yml` has two jobs. **`test`** runs `test:gems` and gates 7, 8, 10 and 14 on
+`.github/workflows/ci.yml` has two jobs. **`test`** runs `test:gems` and gates 7, 8, 13 and 17 on
every Ruby in the matrix — 3.2, 3.3, 3.4 and 4.0 — because those are the gates whose answer
depends on the interpreter: a method absent on the 3.2 floor, or a name that becomes a bundled gem
at 4.0. **`gates`** runs everything else once, on the development Ruby named in `.ruby-version`.
@@ -48,6 +52,10 @@ Every gate body that is more than a subprocess and a message is a module under `
```bash
DEXPACE_GATE_ROOT=test/fixtures/gates/gemspec_audit/two_third_party bundle exec rake gates:gemspec_audit
+DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:cause_walk
+DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:bounded_map
+DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:seam_names
+RUBYOPT=-r./test/fixtures/gates/serde_boundary/pending_probe.rb bundle exec rake gates:serde_boundary
DEXPACE_SURFACE_EXTRA=Injected bundle exec rake gates:surface_snapshot
DEXPACE_SURFACE_EXTRA=Dexpace::Transport::Shared bundle exec rake gates:surface_snapshot # beside a namespace
DEXPACE_FORCE_DUPLICATE=1 bundle exec rake gates:single_instance
diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
index 5c50002..080595b 100644
--- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
+++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md
@@ -1824,7 +1824,11 @@ audit-only**, and **2 were fixed on the spot in this pass** because they were in
substituted proxy IDs and phase 8a's half-stale `TRANSPORT-30` forward row. Bullets whose repair is expected to
be **moot** — the uncapped `Clients#@by_origin`, which `8c`'s plan Task 8 now bounds at planning time — stay
below unchanged, because a green `gates:bounded_map` run is what closes them and a sentence in a plan is not
-evidence the work was done.
+evidence the work was done. [2026-09-24, phase 9: **that condition is now met and this paragraph's one example
+has been discharged.** The green run happened on 2026-09-23 and the bullet below carries the measurement in
+place; the name `@by_origin` was itself wrong on the tree, so the example reads as an unfound defect until the
+bullet's own correction is read beside it. The rule the paragraph states — a sentence in a plan is not evidence
+the work was done — is what the discharge confirms, and it stands.]
**From the reconciliation.**
@@ -1922,7 +1926,23 @@ evidence the work was done.
moot when `8c` lands and phase 10 to find nothing to repair. It stays on this list until a green
`gates:bounded_map` run says so. What made the difference is only that `8c` had **not** in fact already
run when phase 9's audit read its plan, which is the premise the phase-10 routing rested on. Touches `XCUT-14`,
- `TRANSPORT-13`, `NFR-17`.
+ `TRANSPORT-13`, `NFR-17`. [2026-09-24, phase 9: **premise false on the tree, and the map this bullet names
+ does not exist under that name.** The as-built ivar is `@by_key`, not `@by_origin` — `Clients` keys by
+ `(reactor, origin)` and not by origin alone
+ (`gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/clients.rb:56`, the `Key` at :28) — so
+ a search for the name this bullet gives finds nothing. Nor is it uncapped: `Clients#fetch` inserts with
+ `||=` and calls `#drain` inside the **same** `@mutex.synchronize` as the insert (:79-82), and `#drain` is a
+ loop back to `MAX_ORIGINS` (32, `async_http.rb:55`) that evicts clients whose reactor has closed first and
+ then the oldest by insertion order (:126-131), with every evicted client's pool retired outside the lock
+ (:83) — which is `XCUT-14`'s drain clause exactly, so a concurrent insert burst converges to the bound
+ rather than overshooting it permanently. `gates:bounded_map` is **green over the whole tree** — 307 files,
+ six adjudicated exceptions, exit 0, re-run 2026-09-24 on 4.0.6 — so this bullet's own eviction condition,
+ "it stays on this list until a green `gates:bounded_map` run says so", is met. `clients.rb` is one of the
+ six exceptions, carried on `InvariantGates::BOUNDED_MAP_ALLOWED` (`tools/invariant_gates.rb:69-79`) as a
+ **verified false positive of the scan** and never as a silenced defect: the scan sees a Hash assigned to an
+ instance variable and cannot see a cap, and the reason recorded beside the entry is the reading of the
+ file. The `docs/first-release.md` blocker is ticked and closed 2026-09-23, `XCUT-14`'s checklist row is ✅,
+ and **no phase-10 repair is owed** — the fix this bullet specifies had already landed with `8c`.]
- **~~8a's `Adapter#dispatch` leaves its rescue variable unused~~ — first half CLOSED 2026-09-13 — and every
repository tool that parses a filed source carries the same exposure.** As filed, this bullet reported that
`docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance.md:4858`
@@ -2761,6 +2781,52 @@ design.
`RawWireTransport` proof, so it is conformance-gem work for the phase that next touches the suite,
not a fix round's. Touches `TRANSPORT-7` and nothing normative in the code. Recorded by phase 8c's
review round 1 on its docs branch; referred to by date and content, never by ordinal.
+- **`Dexpace::Registry#resolve`'s discovery loop has no bound.** Found 2026-09-23 by phase 9's
+ implementation while cutting a mutation for `XCUT-23`: `#resolve` is `loop do … end` over
+ `take_or_join_claim`/`wait_on`, and a change that makes the `return hand_out(resolved)` guard
+ conditional on anything else turns it into an unbounded spin with no progress — the mutation ran for
+ six minutes and was killed. **The shipped code is correct**; what is missing is a bound or a
+ structural argument that the loop terminates, which phase 2 states nowhere. **Code half: a bound on
+ the loop, or a comment that argues the termination and a test that would catch its loss.** Touches
+ `SEAM-5`, `XCUT-23`. Referred to by date and content, never by ordinal.
+- **`XCUT-12`'s fiber-scheduler form is unasserted, and the checklist row says so.** Phase 9's design
+ already routed this (its "Work phase 9 postponed" table names the thread form only), and the built
+ assertion repeats it in `invariant_suite/resolution.rb`: the single-flight refresh is proven for
+ sixteen THREADS and never for two fibers under a reactor, because a credential path running under a
+ scheduler is a composition no first-party suite assembles. **Code half: the fiber form of the
+ assertion, or a stated decision that it stays out of v1.** Touches `XCUT-12`, `AUTH-34`, `AUTH-37`.
+ Verified still owned on 2026-09-23 rather than re-filed.
+- **`XCUT-9`'s cycle assertion has two stated residues, and `gates:cause_walk` proves neither absent.**
+ A collect-then-yield cause walk never returns a step to count, so a step-bounded drive cannot end it;
+ and a depth cap of exactly three passes a three-node cycle, because a black-box test over one finite
+ input cannot tell a counter from a visited set. Both are written into
+ `invariant_suite/classification.rb` above the assertion. **Code half: a second input of a different
+ length, or a source-shape check over the walk's own file.** Touches `XCUT-9`. Found 2026-09-23 by
+ phase 9's implementation.
+- **7a's `SEAM-21` evidence lives inside another assertion's body and is declared in no ID-keyed map.**
+ Phase 9's `P9-22` re-derived `CodecSuite`'s two portable assertions rather than lifting 7a's, exactly
+ so `assert_closes_nothing`'s `SEAM-21` type-token clause would keep its home — but that clause is
+ still a branch inside a helper, not an assertion with `ids: ["SEAM-21"]`, so
+ `Aggregate.by_requirement_id` cannot see it and `APPENDIX_B.md`'s `B.3` rows point at a file rather
+ than at a check. **Code half: split the clause into its own assertion in `dexpace-serde-json`'s
+ suite, or state in the map that the row is a file reference.** Touches `SEAM-21`, `SERDE-3`. Found
+ 2026-09-23 by phase 9's implementation.
+- **`tools/surface.rb` cannot see a `private_constant` module, so a group that became public would be
+ invisible to `gates:surface_snapshot` until one of its methods leaked.** The walk is
+ `Module#constants(false)`, which omits a private constant entirely — which is what lets phase 8a's
+ and phase 9's group modules stay out of the manifest, and equally what hides the transition. Phase 9
+ found two real public-surface leaks (`Aggregate#add_rows` and `CodecSuite`'s two assertion factories)
+ by reading the regenerated manifest row by row, not by a gate. **Code half: have the walk record a
+ private constant's NAME with a marker, so the manifest shows the transition without exporting the
+ contents.** Touches `NFR-4`. Found 2026-09-23 by phase 9's implementation.
+- **`Dexpace::Conformance::PackagingCase`'s default gem-name → constant-path rule covers none of this
+ port's exceptional four.** The rule is segment-for-segment (`dexpace-transport-net_http` →
+ `Dexpace::Transport::NetHttp`), and the port's own names need `Dexpace` for `dexpace-core` and
+ acronym casing for `NetHTTP`, `AsyncHTTP` and `JSON`, so four of six gems need the `constants:`
+ override a driver supplies. A driver that omits it gets a `:vacuous` result with an actionable
+ message and never a wrong answer, which is why this is a repair and not a defect. **Code half: a
+ default that covers a root-namespaced gem and a known-acronym leaf, with the override kept for
+ everything else.** Touches `NFR-15`. Found 2026-09-23 by phase 9's aggregate run.
**2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the
plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of
@@ -5044,3 +5110,174 @@ knowledge-structure verifier and both process-tooling suites, with every `ruby`
ephemeral port), `conformance.md`'s changed fences on both and `async-thread.md`'s blocks once more on
4.0.6. `main` is `a7cfeb6` before and after this pass; nothing is pushed, and umbrella #29 stays open for
both.
+
+**2026-09-23** — **Phase 9 implemented**, as three stacked branches against issue #33: code
+(`9-phase-9-cross-cutting-invariants-and-conformance`), tests (`…-tests`) and documentation
+(`…-docs`), each targeting the one below and the first targeting `main` at `582e33a`, which holds every
+phase through 8. Nothing is pushed. **This is the audit phase, and it repaired nothing it found**:
+design `R6` makes the repair phase 10's, and the file list the plan fixed held with one named
+bend — `gems/dexpace-conformance/`, `tasks/`, `tools/`, `test/`, `.github/workflows/` and the root
+`Rakefile`, plus each adapter gem's `test/` tree, **plus exactly one ADDED file under
+`gems/dexpace-core/test/`**, the first-party `InvariantSuite` driver, with no existing core file
+modified, renamed or deleted. That is the one place the file list bends, it is the sentence the
+design's own `P9-9` needs and its Module layout denies, and `P9-40` records the contradiction rather
+than editing either away. **No `lib/` or `sig/` file outside `gems/dexpace-conformance/`
+was touched, in any gem**, and `git diff --name-status main.. -- gems/dexpace-core` is
+that one path, status `A`.
+
+**What it built.** Four suites beside phase 8a's transport one, all over one shared `Runner` so the
+five statuses are decided in one place — `InvariantSuite` (28 assertions over all twenty-four `XCUT`
+ids, in ten `private_constant` groups), `PackagingSuite` (8 over `NFR-1`, `2`, `3`, `10`, `11`, `13`,
+`14` and `15`, read from **published** `Gem::Specification` metadata rather than a source gemspec),
+`CodecSuite` (2, re-derived from the requirement text rather than lifted, so `dexpace-serde-json`'s
+files stay byte-identical and keep the three properties a lift would have dropped) and `ExecutorSuite`
+(7, `SEAM-25`'s harness half among them) — with `Levels` (645 ids generated from appendix C) making a
+MUST-level vacuity a report blocker, `SharedInstance` as `XCUT-11`'s structural predicate, `Aggregate`
+giving a whole run one verdict and a preamble that prints what green does not prove, and
+`APPENDIX_B.md`'s 61-row coverage map. Three repository-wide invariant scans became blocking gates over
+one `RubyVM::AbstractSyntaxTree` walker — `gates:cause_walk`, `gates:bounded_map`, `gates:seam_names` —
+taking the set to **twenty-one**, all in `DEFAULT_GATES` and all in `ci.yml`'s `gates` job. Three
+first-party drivers, one per suite with a subject in this repository.
+
+**The audit's verdict.** The aggregate run over all four suites with the real subjects — 8a's adapter
+as `transport:`, so `XCUT-18`'s call-site assertion runs against a real dispatch path — is **43 passed,
+1 failed, 0 vacuous, 1 waived, 0 errored**. `PackagingSuite` was run a second time the way design
+`P9-2` asks, against the six gems **built and installed outside the bundle**: **7 passed, 1 failed, 0
+vacuous**. The one failure both times is `NFR-13`: **307 of 307 shipped `.rbs` files carry no SPDX
+header**, in all six gems, because a RuboCop cop parses Ruby and cannot reach `.rbs`. That is already
+phase 10's by name — its design addendum `A8` and inbound row 8 plan `gates:spdx_rbs` and say "phase
+9's presence assertion turns green" — so it is verified still owned and **not** re-filed. The one
+waiver is `ASYNC-3`, printed `waived (would fail)`, with a second test that runs it unwaived and
+requires the failure, so the waiver cannot outlive the limitation.
+
+**One `docs/first-release.md` blocker closes.** `gates:bounded_map` is green over all 307 files in the
+six gems' `lib/` trees. `dexpace-transport-async_http`'s `Clients` is on the allowlist as a **verified
+false positive of the scan** and not as a silenced defect: the as-built ivar is `@by_key`, not the
+`@by_origin` the planning note named, and `#fetch` calls `#drain` inside the same `@mutex.synchronize`
+as the insert with `#drain` a loop — `XCUT-14`'s drain clause exactly. So `XCUT-14`'s row is ✅, no
+phase-10 repair is filed for that cache, and the plan's one expected red run did not happen.
+
+**Four things earlier phases postponed here have landed.** *(a)* The **conformance assertion
+protocol**'s second half — 8a said the protocol landed; the remaining suites are these four, plus
+`Runner`, `Check`, `SharedInstance`, `Levels`, `Aggregate` and `Report#to_h`. *(b)* The **portable
+wire-boundary assertion** phase 1 named for this phase: a forged `Request` that never met a builder is
+driven at a transport factory and refusal is asserted before any wire activity, with a listener the
+assertion owns as the witness. *(c)* **`SEAM-25`'s harness half**, and this is a **correction to a
+committed record, not a restatement of one**: 8b's design assigned that half to 8a, 8a wrote no
+executor suite, so it was unwritten after phase 8 and phase 9 writes it. *(d)* The **MUST-level vacuity
+blocker** phase 9 itself postponed on 2026-09-13, landed before the disposition run.
+
+**Six findings routed and none repaired**, each by date and content on phase 10's inbound list above:
+`Registry#resolve`'s unbounded discovery loop, `XCUT-12`'s unasserted fiber-scheduler form, `XCUT-9`'s
+two stated residues, 7a's `SEAM-21` evidence living inside another assertion's body,
+`tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default
+constant-path rule.
+
+**Guards.** Sixty-two mutations were run one at a time through a harness that applies the edit, runs
+the owning suite, captures the first failure and restores the file — **every one red**, each recorded
+in the checklist with the message it produced: forty-four at implementation, seven in review round 1,
+two in review round 2 — shapes that SURVIVED round 1 rather than shapes newly thought of — seven
+in review round 3, which neutralise one of this phase's OWN assertions whole rather than mutating a
+subject, the only shape that proves a committed double can report `:failed`, and two in review round 4
+for the last assertion that had none. The checklist's earlier
+claim of "one mutation per assertion" was simply untrue and is corrected there with the rows that were
+missing. Six shapes were re-cut: one spun forever and was killed
+rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and
+**two of those five were not bad mutations but real non-discrimination in this phase's own
+assertions**: `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers
+could finish one at a time. Both now assert a parking barrier as a `Check` of its own, so a run that
+could not have discriminated reports `:failed`. The reviewer's own mutation list did not survive this
+session's context compaction and the set run is therefore **this phase's**, re-derived from the
+design's testing strategy — recorded as `P9-32` rather than passed off as the list it replaces. Two
+further defects were found by building the failing fixtures this repository requires of every gate, and
+both are fixed because both are phase 9's own code: the three new gates' `DEXPACE_GATE_ROOT` route
+opened every file relative to the process's CWD and could never have been shown to reject anything, and
+`gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test at all.
+
+**Proofs**, re-measured at review round 3's tips on 2026-09-24; the run counts moved again, because
+that round added thirteen tests, and the coverage denominators did NOT, because it added no `lib/`.
+The **code tip** is green on **every one of the twenty-one gates run individually** on 4.0.6, the coverage floor
+included — **4,020 runs, 74,125 assertions, 0 failures, 0 errors, 7 skips** and **94.65 % line
+coverage, 11,689 of 12,349** — the one red the layering rule tolerates was not needed, because the
+floor is met there even though the suites that raise the figure are the branch above's — `test:gates`
+145 runs and 703 assertions with no skip, and the honest RuboCop run over 758 files clean; and on the
+five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 skips, 11,106 of 12,210 lines,
+**90.95 %**), where the three new gates report the same 307, 307 and 222 files they do on 4.0.6. The
+**tests tip** is green on the whole default task on 4.0.6 (**4,204 runs, 74,610 assertions, 0
+failures, 0 errors, 9 skips** and **99.82 % line coverage, 12,327 of 12,349**; `cops:test` 129 runs
+and 412 assertions; `test:gates` 193 runs and 877 assertions with no skip; the honest RuboCop run
+over 776 files clean) and on the matrix set on 3.4.10 (4,204 / 74,610 / 9 skips, the same 99.82 %),
+3.3.12 (the same runs, 12,181 of 12,203 lines) and 3.2.11 (4,016 / 73,834 / **5** skips, 96.09 % —
+the four `async_http` skips are absent because that gem's 3.3 floor excludes it from the 3.2 row).
+Three explicit seeds — 1, 99991 and 424242 — give the same 4,204 / 74,610 / 9 on 4.0.6, and the four
+timing-sensitive files run ten times each under a shell `timeout` with identical counts every time.
+The two coverage figures are not interchangeable and were once transposed here: 90.95 % is the
+**3.2.11** row and 94.65 % the 4.0.6 one. **Every skip is named**: the seven phase-8 drivers' —
+`net_http`'s three and `async_http`'s four — plus this phase's two, `XCUT-18`'s call-site assertion in
+core's driver (`vacuous: no transport factory supplied to InvariantSuite.run`, and real in the
+aggregate run) and `ASYNC-3` in the pool's (`waived: ASYNC-3`, and proven to fail by a test of its
+own). The **docs tip** is green on the default task (the same figures), the honest RuboCop run,
+`ruby .claude/skills/housekeeping/probe.rb` (exit 0, no drift) and
+`ruby scripts/verify_knowledge_structure.rb`, with every `ruby` fence of `conformance.md`'s new
+section run verbatim on 4.0.6 and 3.2.11 — the same printed values on both. Beside the gates: the
+aggregate run over all four suites with the real subjects, reproduced at these tips and unchanged at
+43 / 1 / 0 / 1, and `PackagingSuite` a second time against the six gems built and installed outside
+the bundle.
+
+**Review round 1, 2026-09-24.** Two defects in this phase's own gem were repaired — `R6` bars phase 9
+from repairing what it AUDITS, and `dexpace-conformance` is what it wrote. Three waits in the new
+suites carried no bound, and one of them HUNG rather than failing: `ExecutorSuite`'s `ASYNC-3`
+assertion parked for ever against an executor that refuses the post, which is `ASYNC-2`'s saturated
+queue and this gem exists to be run by third parties against theirs. All three now carry 8a's own
+`await_closed_connection` rule (`P9-34`). And `PackagingCase::DEFAULT_RESOLVE` rescued
+`::StandardError` alone, so the `Gem::MissingSpecError` it exists to catch — a `ScriptError`
+descendant — escaped it and `Runner`'s bare rescue both, aborting a whole run where the contract is
+one `:vacuous` naming the absent unit (`P9-35`). Both were found by writing the tests round 0 asked
+for. Four documents were corrected against measurement rather than restated, the coverage
+transposition above among them.
+
+**Review round 2, 2026-09-24.** Two guards this phase wrote could not fail, and both were in the
+instrument rather than in what it measures — which is the failure mode an audit phase has to take
+most seriously, because a green assertion that cannot go red is indistinguishable from a property
+that holds. `InvariantSuite`'s `XCUT-22` made a second `Check` whose `usable?` was the negation of
+the `closed?` the first already read, so the second could not fail on its own; `Borrowed` now carries
+`#finish`, the adapter-side teardown reached without going through `#close`, and a holder using it
+passes the first and fails the second (`P9-38`). `ExecutorCase#shutdowns` matched the payload's event
+name, but every double emitted the shutdown payload and nothing else, so nothing separated "one
+shutdown event" from "one sink write" while four assertions read that count; a chatty-but-conforming
+pool now logs something besides its shutdown (`P9-39`). Beside them the checklist gained what plan
+Task 15 Step 3 asks for in as many words — the three `NFR` marks where the design's ⏳ prediction was
+WRONG, named as wrong (`P9-36`) — and the `gates:bounded_map` allowlist re-adjudication got the
+ledger row its reasons belonged in (`P9-37`).
+
+**Review round 3, 2026-09-24.** The same failure mode again, and at its widest: **seven of
+`InvariantSuite`'s twenty-eight assertions had no committed non-conforming double**, and five of them
+— `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7` and `XCUT-10` — could be neutralised WHOLE with the entire
+`rake test:gems` green. Nothing in the repository drove a subject that made any of their clauses fail,
+so each reported `:passed` because it could not report anything else, while the checklist told a
+reader that every `XCUT` row had been driven against a deliberately non-conforming double and
+`invariant_suite_core_test.rb`'s own header named three of those IDs among the ones it drove. In an
+audit phase that is `R3`'s failure one level up: `R3` stops an assertion passing because its subject
+was never built, and this was an assertion passing because its own falsifiability was never built.
+Thirteen defective stand-in cores now drive all seven — the five plus `XCUT-19` and `XCUT-24`, whose
+apparent coverage was an artefact of `-w` raising on a local the mutation orphaned — and neutralising
+each assertion whole and warning-free reddens exactly the new tests for it (`P9-41`, rows 55–61). No
+first-party code changed and none should have: the assertions were right and only the negative
+controls were missing. `XCUT-3` and `XCUT-12` are still without one, deliberately and by name: a
+double for either would have to stay in the wait past the assertion's own bound, which leaks the
+thread the base case's teardown counts. Beside it the round recorded `R6`'s one sanctioned exception,
+which the tree had carried since implementation and no document named (`P9-40`).
+
+**Review round 4** closed the last of that class. Round 3's sweep neutralised all forty-five assertion
+bodies in the four suites one at a time and left four green; three were accounted for by the checklist
+or are a private clause helper whose parent assertion is caught, and the fourth — `XCUT-18`'s
+model-layer `header_syntax_validation` — was named nowhere and could be neutralised whole with the
+whole of `rake test:gems` green. The claim that covered it counted **rows**, and `XCUT-18` carries two
+assertions, so the row was covered on one of them; the checklist's evidence paragraph now counts
+assertions and names the sweep as what makes it checkable. Two defective cores drive it — a permissive
+validator, and one applying the NAME rule to outbound values as well, so the name/value asymmetry has a
+control of its own (`P9-42`, rows 62–63). The round also corrected two counts in the record: the core
+driver declares **twenty** shared instances, not fifteen, in the checklist's `XCUT-11` row and in
+`docs/sdk-documentation/conformance.md`; design `R8`'s nine is a different list and stands.
+
+`main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open.
diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md
new file mode 100644
index 0000000..a096874
--- /dev/null
+++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md
@@ -0,0 +1,532 @@
+# Phase 9 — Cross-Cutting Invariants and Conformance: Checklist
+
+**Written at execution time, 2026-09-23, from what was built** — not from the plan. A row whose task did
+not do what the plan said is a row that says so, and "Deviations from the plan" below states each
+departure with its reason. The design and the plan were written on 2026-09-12 (reviewed 2026-09-13)
+against phases 0–8's documents; since then the whole of phases 7 and 8 was built and merged, and this
+phase was cut from `main` at `582e33a`, which holds all of them. Where the plan's text and the built tree
+disagree the tree wins and this document records it.
+
+**Phase 9 reports; phase 10 repairs (design `R6`).** Nothing outside `gems/dexpace-conformance/`,
+`tasks/`, `tools/`, `test/`, `.github/workflows/`, each adapter gem's `test/` tree and the root `Rakefile`
+was changed — **plus exactly one ADDED file under `gems/dexpace-core/test/`**, the first-party
+`InvariantSuite` driver, with no existing core file modified, renamed or deleted. That is the one place
+`R6`'s file list bends and it is recorded rather than left to be discovered: deviation 14 below, `P9-40`,
+and the design's As-built addendum, which names the two sentences that cannot both hold. No `lib/` or
+`sig/` file outside `gems/dexpace-conformance/` was touched at all, in any gem, and
+`git diff --name-status main.. -- gems/dexpace-core` is that one path, status `A`. Every
+defect this phase found in another phase's code is routed with its evidence and left unrepaired —
+"Findings routed" below — because a phase that repairs what it found destroys the evidence that it was
+ever there.
+
+Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested ·
+🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase, task
+number and path — that will do it, or the `docs/first-release.md` entry that owns it) · N/A not
+applicable in this port.
+
+Plan: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md`. Task numbers
+are that plan's — seventeen numbered tasks plus Task 12a, eighteen headings. Design:
+`docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, whose
+Deviation Ledger carries `P9-1`–`P9-10` and whose As-built addendum, written with this checklist, adds
+**`P9-21`–`P9-42`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered).
+Test files are named with their gem: `conformance/…` is
+`gems/dexpace-conformance/test/dexpace/conformance/`, `core/…` is `gems/dexpace-core/test/dexpace/`,
+`gates/…` is `test/gates/`, `serde_json/…` is `gems/dexpace-serde-json/test/dexpace/serde/json/` and
+`async_thread/…` is `gems/dexpace-async-thread/test/dexpace/async/thread/`.
+
+---
+
+## Requirement rows
+
+**Forty-one own rows** — `XCUT-1`–`24` and `NFR-1`–`17` — plus the cross-reference rows below for the IDs
+this phase's suites exercise and do not own. **Thirty-six ✅, two N/A (`NFR-8`, `NFR-9`), one ⏳
+(`NFR-16`), one ✅ with a stated partial (`NFR-12`) and one ✅ / ⏳ (`NFR-13`, whose assertion is built and
+whose assertion FAILS against the tree)**; nothing 🚫.
+
+**The aggregate run's verdict**, Task 16 Step 2, over all four suites with the real subjects and 8a's
+adapter supplying `transport:`, on 2026-09-23 under Ruby 4.0.6:
+
+```
+43 passed, 1 failed, 0 vacuous, 1 waived, 0 errored
+ waived (would fail): ASYNC-3 (cancelling a task blocked on a worker releases the worker within a bound)
+ FAILED: NFR-13: shipped signature files carry no SPDX header
+passed? false
+```
+
+That `false` is the instrument working, not the instrument broken: one SHOULD-level failure with a named
+owner and one waived MUST that is declared to fail. Nothing is vacuous — every factory is supplied —
+and `XCUT-18`'s call-site assertion, which is vacuous in core's own driver because core ships no
+adapter, runs for real here.
+
+**`PackagingSuite` was run a second time against what a consumer actually resolves**, which is what
+design `P9-2` asks for and what a run inside `bundle exec` cannot give: the six gems were built with
+`gem build`, installed into a scratch `GEM_HOME` outside the repository, and the suite run with every
+`BUNDLE_*` and `RUBYOPT` key cleared. **7 passed, 1 failed, 0 vacuous, 0 waived** — `NFR-1`, `NFR-2`,
+`NFR-3`, `NFR-10`, `NFR-11`, `NFR-14` and `NFR-15` against the PUBLISHED `Gem::Specification` and the
+`sig/` trees inside the built `.gem` files, and the same `NFR-13` failure. That run is also the
+evidence that every gem's `sig/` mirror SHIPS: the scan walked it inside the installed gem and not in
+the repository.
+
+**Prediction versus observation**, which the plan's Task 15 Step 3 asks for by name: *a prediction
+is not an observation*, and where the two differ the row says which prediction was wrong. The design's
+`NFR` disposition table, written on 2026-09-12, predicted **six ⏳**. **Three of those predictions were
+wrong** and the rows below are ✅ on observation, each naming its own. **`NFR-7`**'s ⏳ was pending
+phase 0 plan Task 3's reviewed `.rubocop.yml` baseline; whatever that baseline said when phase 0 wrote
+it, the honest RuboCop run over the `.rubocop.yml` the repository carries exits 0 on `main` at
+`582e33a`, before this phase changed anything. **`NFR-10`**'s was pending 8c plan Task 3's per-gem Ruby
+floor gate edit; that edit landed — `VERSIONS` carries `ruby floor:dexpace-transport-async_http 3.3`
+and `gates:versions` is green. **`NFR-17`**'s was pending phase 0 plan Task 2's `minitest ~> 5.25` pin,
+the one non-blocking-gate finding the design itself had raised; that pin landed with its reason in
+`VERSIONS`' own comment, so the meta-audit starts from green with nothing outstanding to report.
+**`NFR-4`**'s ⏳ is overturned as a MARK only, and narrowly: both gates run and the row is ✅, but
+`gates:sig_diff` still prints its own vacuity because the `v*` tag it needs does not exist, which is
+what the design predicted. **`NFR-13`**'s and **`NFR-16`**'s predictions HELD — the SPDX assertion
+fails as predicted, and there is still no release path.
+
+A fourth correction observation forced is not an `NFR` mark at all but the narrative behind
+`gates:bounded_map`'s one expected red, and it is deviation 4 below. Every one of the four was MADE
+from measurement before Task 15's disposition run, so each mark is an observation and not a
+transcription — but only the `gates:bounded_map` one was written down AS a correction at the time.
+Naming the other three as overturned predictions is review round 2's repair (`P9-36`): a mark that
+silently disagrees with the prediction it replaces leaves the next reader unable to tell which it is,
+and phase 10 reads this document first.
+
+Every `XCUT` row's evidence is the same three-part shape and is stated once here rather than repeated
+forty-one times: **(a)** the assertion in `dexpace-conformance`, which names no first-party constant it
+cannot reach through `InvariantCase`; **(b)** that assertion driven against a deliberately
+non-conforming double in `conformance/invariant_suite_test.rb` (or `…_core_test.rb`), which is what
+proves the assertion can fail at all; and **(c)** the same assertion driven against the real SDK by
+`core/cross_cutting_invariants_test.rb`, the first-party driver. A row naming a mutation names one from
+"Guards run red" below, run against the real subject through (c).
+
+**(b) holds for every `XCUT` ASSERTION but two, and the two are named here rather than left to be
+found.** The unit is the assertion and not the row, which is review round 3's correction: an ID appears
+once however many assertions carry it, so a row can be covered on one assertion and bare on the other.
+`XCUT-18` was exactly that, and the bare one could be neutralised whole with the whole of
+`rake test:gems` green (deviation 16; `P9-42`). What makes the sentence checkable rather than asserted
+is the sweep behind it: every assertion body in the four suites neutralised whole, one at a time, run
+in review round 3 over all forty-five and leaving four green — `XCUT-3`'s, `XCUT-12`'s, `XCUT-18`'s
+model layer and one private clause helper whose parent assertion is caught. Round 4 closed `XCUT-18`'s.
+The two that remain, `XCUT-3`'s and `XCUT-12`'s, carry no committed non-conforming double: either would
+need a subject that stays in the wait past the assertion's own half-second bound, which leaks a thread
+`DexpaceTestCase`'s teardown counts, so their evidence is (a) + (c) + a hand-run SUBJECT mutation — 22
+and 23 below. **Thirteen doubles were added in review round 3 and two more in round 4.** Until then
+seven assertions had no (b) at all — `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19` and
+`XCUT-24` — and five of the seven could be neutralised WHOLE with `rake test:gems` green,
+which is the difference between an assertion that holds and one that cannot report anything else
+(deviation 15; `P9-41`).
+
+| ID | Level | Status | Task(s) | What was built, and where it is proven |
+|---|---|---|---|---|
+| `XCUT-1` | MUST | ✅ | 8, 16 | "a cancellation is terminal and never retried": the token's `#check!` surfaces `Dexpace::CancelledError`, `Policy.throwable_retryable?` answers false for it, and `Policy.cancellation?` still recognises it when an adapter's own retryable error wraps it — the third clause is the one a check reading only the outermost error would miss. Two defective stand-in `Policy` cores — one reading only the outermost error, one calling a cancellation retryable. Mutations 12, 13 and 55 |
+| `XCUT-2` | MUST | ✅ | 8, 16 | "a timeout is retryable and told apart from a cancellation by state": a `TransportError(phase: :read)` classifies retryable, the ambient flag stays clear, and a SUBTYPE of `CancelledError` is still recognised as a cancellation — the subtype clause is what makes "by the type, never by a message string" real. Two defective cores — one matching the cancellation type EXACTLY, so a subtype escapes, and one telling a timeout apart by its message. Mutation 56 |
+| `XCUT-3` | MUST | ✅ | 8, 16 | "a pending inter-attempt wait is promptly cancellable": a thirty-second `Clock::SYSTEM#sleep` cancelled from another thread surfaces `CancelledError` inside half a second. The waiter must be **parked** when the cancel is issued, and that is asserted rather than assumed — without the barrier the cancel could beat the waiter into the wait and an implementation ignoring the token passed (`P9-28`). **No committed non-conforming double**, for the reason the evidence paragraph gives — a subject that stays in the wait leaks its thread — so the guard is mutation 22, run against the real `Clock` |
+| `XCUT-4` | MUST | ✅ | 6, 16 | "the error taxonomy has two branches and a transport error is I/O-family": `ProtocolError` carries a status and `TransportError < ::IOError` does not, and `Dexpace::Error` is a MODULE both reach through `Module#===` |
+| `XCUT-5` | MUST | ✅ | 6, 16 | "one shared status classifier decides the baked retryability flag": `Retryability.retryable_status?` is the one classifier and `ProtocolError#retryable_by_status?` reads it — never `#retryable?`, which is `XCUT-6`'s open capability (6a's `P6-10`) |
+| `XCUT-6` | MUST | ✅ | 6, 16 | "a custom error's retryability capability is queried, not its type": `Policy.throwable_retryable?` probes `#retryable?` over every cause and never a class list, so a caller's own error type is honoured. Two defective cores — one reading only the outermost error, one matching a concrete TYPE. Mutation 57 |
+| `XCUT-7` | MUST | ✅ | 6, 16 | "the configured retryable-status set is authoritative and can widen or narrow": a set admitting 404 admits it and one refusing 408 refuses it, and the default set is the six `RETRY-13` fixes. Two defective cores — a set ANDed with the baked classifier, which passes the narrowing half and fails the widening one, and a wrong default set. Mutation 58 |
+| `XCUT-8` | MUST | ✅ | 6, 16 | "the status-to-exception factory refuses a non-error status": `ProtocolError.for` raises `InvalidArgumentError` on a 200, `.for_or_nil` answers nil, and the same factory still maps a 503 — both forms in one assertion |
+| `XCUT-9` | MUST | ✅ | 6, 13, 16 | "the cause walk terminates on a cyclic chain, tracking by reference identity": a THREE-node cycle (two is indistinguishable from a depth-2 walk) driven through `Enumerator#next` under a step bound, so a non-terminating walk reports `:failed` rather than hanging. Beside it `gates:cause_walk`, the parsed scan that keeps the walk in one file. Mutation 24 |
+| `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on. Two defective cores — a gate that re-sends a bare POST, and one taking a failure parameter. Mutation 59 |
+| `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares **every shared instance core publishes**, each with the ivars it is allowed to hold — twenty at this tip: eighteen frozen, which conform on `frozen?` alone whatever they hold (seven of them do hold state), and two unfrozen, `ContextStore.default` and `Pipeline.standard`, which declare exactly what they hold (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). The number is read off that factory rather than kept by hand here; design `R8`'s **nine** is a different list — the subjects it names across five phase documents — and both are right. Mutations 34, 35 and 44 |
+| `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list. **No committed non-conforming double**, for the same reason as `XCUT-3`: sixteen racers that never finish leak sixteen threads. The guard is mutation 23, run against the real `BearerStamper` |
+| `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Appendix C's third clause, "preserves the ambient interrupt/cancel flag as-is", is **scoped out with its reason** in `executor_suite/lifecycle.rb`'s own comment and asserted nowhere: §8.3 bans every primitive that could arrange a pending interrupt, so the clause holds by the flag never being touched and there is nothing observable to assert. Mutations 2 and 36 |
+| `XCUT-14` | MUST | ✅ | 7, 13, 16 | Two assertions plus `gates:bounded_map`. The cap clause reads the size after EVERY insert across 320 of them, so an implementation that overshoots and trims is caught; the DRAIN clause fills the backing store to cap + 5 — the state a concurrent overshoot leaves, arranged without a thread — and performs one `#set`: a drain loop ends at the cap, a check-then-evict at cap + 5, on every interpreter. Mutations 6 and 7 |
+| `XCUT-15` | MUST | ✅ | 5, 16 | "public wire models retain no external-mutable alias": the mutation is on the INNER array of the `values:` Hash, because a port that dup'd only the outer Hash passes a top-level check. Mutation 1 |
+| `XCUT-16` | MUST | ✅ | 7, 16 | All three clauses: the plaintext refusal is `Auth::HTTPSRequiredError` BY CLASS, an HTTPS request really is stamped (so the refusal is about the scheme and not a broken path), and a marker-suppressed cross-origin re-issue proceeds over `http` carrying no credential. Mutations 19 and 20 |
+| `XCUT-17` | MUST | ✅ | 7, 16 | All four clauses in one assertion, because a port can satisfy any three: `Authorization` stripped on a SAME-origin re-issue, all three origin-scoped headers on a cross-origin one, the `Location`'s userinfo gone, and an HTTPS→HTTP downgrade refused. Mutations 15–18 |
+| `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. **Each assertion carries its own (b)**, which is the distinction review round 3 forced: the model layer's are a permissive validator and one applying the NAME rule to outbound values as well, added in review round 4 and proven by mutations 62 and 63 — until then this was the one assertion in the suite that could be neutralised whole with `rake test:gems` green (deviation 16), and the call site's are the two transports in `conformance/invariant_suite_core_test.rb`. Mutations 4 and 5 are the hand-run SUBJECT mutations of core's own `HeaderSyntax`, one per side of the asymmetry. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real |
+| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Two defective cores — a redactor forwarding the URL's userinfo, and a default-ALLOW header list. Mutations 9, 10 and 60 |
+| `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 |
+| `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 |
+| `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves, on two INDEPENDENT routes: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. That second route is `Borrowed#finish`, added in review round 2 — until then `usable?` was the negation of the `closed?` the first Check reads, so the second could not fail on its own (`P9-38`). `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`, and posts work to it, which is an independent observation of its own. Mutations 3, 40 and 54 |
+| `XCUT-23` | MUST | ✅ | 8, 16 | Three ordered rules and the ORDERING is the content: zero candidates fail loudly, two candidates fail loudly, an explicit install beats a discovered one, and a single candidate is discovered. "Loud" is checked for its own reason — the message must name the explicit-install entry point. Mutations 21b and 25 |
+| `XCUT-24` | SHOULD | ✅ | 7, 16 | Both subjects, because the halves live in different objects: 4b's error-body snapshot is the cap and 3b's logging tap is the non-consumption. A preview that capped but drained would pass the first alone. One defective core — a snapshot that multiplies its cap. Mutations 8 and 61 |
+| `NFR-1` | MUST | ✅ | 9, 15 | Asserted TWICE against two subjects (`P9-2`): phase 0's `gates:gemspec_audit` over the source gemspec, and `PackagingSuite`'s "core declares zero runtime dependencies" over the resolved `Gem::Specification` a consumer actually gets. `gates:require_allowlist` and `gates:clean_bundle` are the same claim's other two mechanisms and all four are green on every matrix row |
+| `NFR-2` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each adapter declares the core plus at most one library": `net_http` spends `net-http`, `async_http` spends `async-http`, `serde-json` spends `json`; `async-thread` and `conformance` spend none. Green against the built `.gem` files outside the bundle |
+| `NFR-3` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "every shipped implementation file has a signature beside it" — the `sig/` mirror as a property of the PACKAGED gem, not of the repository — beside `rbs validate` and `steep check` over six targets, both green with no relaxation added by this phase |
+| `NFR-4` | SHOULD | ✅ | 15, 17 | `gates:sig_diff` (vacuous until the first `v*` tag, and it says so) and `gates:surface_snapshot` over the six manifests, regenerated deliberately once in Task 17 and reviewed row by row. This phase's rows are `dexpace-conformance`'s alone |
+| `NFR-5` | SHOULD | ✅ | 15 | `rake test:gems` with SimpleCov `minimum_coverage 80`; measured at the tests tip |
+| `NFR-6` | SHOULD | ✅ | 15 | `ruby -w` plus `RUBYOPT=-W:deprecated` with `DexpaceTestCase::FatalWarnings` raising, over one process holding all six gems' suites. This phase's `test/support/gate_warning_capture.rb` deliberately does NOT arm it, for the reason `P9-24` gives |
+| `NFR-7` | SHOULD | ✅ | 15 | `rake rubocop` (findings fatal, no autocorrection) and `rake cops:test`. The honest command in a nested worktree is `bundle exec rubocop --fail-level=convention --ignore-parent-exclusion`, which is what was run: 776 files, no offences. **The design predicted ⏳**, pending phase 0 plan Task 3's reviewed `.rubocop.yml` baseline; that prediction was wrong — the baseline landed and the honest run is clean on `main` before this phase touched it |
+| `NFR-8` | MUST | N/A | 15 | Ruby has no whole-program dead-code elimination, and the requirement exempts itself by its own text. §10.19 retargets it at the require-allowlist audit and the clean-bundle isolation run, both dispositioned under `NFR-1` rather than counted twice (`P9-5`) |
+| `NFR-9` | SHOULD | N/A | 15 | The same: there is no shrinker keep-configuration to guard. The part of its CONTENT that does apply — a guard that the require-allowlist is still the RIGHT list on a new interpreter — is postponed with its trigger named (`docs/first-release.md` § Post-release triggers, the require-allowlist entry) |
+| `NFR-10` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit declares a runtime floor, and a higher one stays isolated" — `dexpace-transport-async_http`'s 3.3 floor must not leak into the other five — beside `gates:versions`. **The design predicted ⏳**, pending 8c plan Task 3's per-gem Ruby floor gate edit; that prediction was wrong — the edit landed, `VERSIONS` carries the `floor:` row and `gates:versions` is green |
+| `NFR-11` | SHOULD | ✅ | 9, 15 | `gates:rbs_surface` over `sig/**` plus `PackagingSuite`'s "the core's public signatures name no async-framework type", whose leak regex anchors on the START of a constant path so core's own `Dexpace::Async::Future` is not a match. Mutation 41 |
+| `NFR-12` | SHOULD | ✅ (partial) | 15 | `gates:reproducible` — byte-identity for one gem built twice on one interpreter, which is phase 0's `P0-7` reading. The cross-toolchain half is explicitly out of scope and is release-gated (`docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry) |
+| `NFR-13` | SHOULD | ✅ / ⏳ | 9, 15, 16 | ✅ for the `lib/` half — the `Dexpace/SpdxHeader` cop over every Ruby file — and for the ASSERTION over the `sig/` half, which is what this phase owed. ⏳ for the `sig/` half's CONTENT: the assertion **fails, correctly**, and that failure is this phase's verdict rather than a defect in it. Measured on 2026-09-23: **307 of 307 shipped `.rbs` files carry no SPDX header, in all six gems.** A RuboCop cop parses Ruby and cannot reach `.rbs`, so nothing checked them. **Owner: phase 10's design addendum `A8` and inbound row 8, which already name this assertion and say "phase 9's presence assertion turns green"** — verified still owned on 2026-09-23, not re-recorded |
+| `NFR-14` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "every unit's version comes from one source of truth" against the resolved specs, beside `gates:versions` over the repository-root `VERSIONS` file |
+| `NFR-15` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each unit reports its build version at runtime": the loaded `VERSION` constant compared against the resolved gemspec's, run against locally built `.gem` files outside the bundle |
+| `NFR-16` | SHOULD | ⏳ | — | Artifact signing is "enforced on the release/CI path" and there is no release path. No phase-9 task writes an assertion for it and none is shipped as a placeholder. **Owner: `docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry** |
+| `NFR-17` | MUST | ✅ | 13, 15 | Every gate this phase adds is in `DEFAULT_GATES` in the root `Rakefile` — not in `tasks/gates.rake`, which the `Rakefile` `load`s before defining and freezing the array — so all **twenty-one** are in `task default:` and therefore blocking, and all three are in `ci.yml`'s `gates` job. `gates/default_task_test.rb` asserts the count and the membership, and phase 0's `ci_workflow_test.rb` asserts every `DEFAULT_GATES` entry appears in some CI job. **The design predicted ⏳**, pending phase 0 plan Task 2's `minitest ~> 5.25` pin — the one non-blocking-gate finding the design itself had raised; that prediction was wrong, because the pin landed, so the meta-audit found nothing outstanding to report |
+
+### Cross-reference rows
+
+One per ID this phase's suites assert and another phase owns. Each names the owning phase's checklist
+row, which is not re-marked here.
+
+| ID | Owner | What this phase's suite adds |
+|---|---|---|
+| `SEAM-2` | phase 2 | `gates:seam_names`, the parsed scan asserting core names no adapter's leaf namespace — by leaf pair and not by a literal spelling, because a fixed list of fully qualified names caught none of four realistic shapes |
+| `SEAM-12` | phase 2 | `ExecutorSuite`'s "a shared executor takes work from many threads", sixteen concurrent posts asserted on the collected set and never on timing |
+| `SEAM-20` | phase 2 | `CodecSuite`'s "a codec never closes a target it was handed", with the bytes checked too so a codec that wrote nothing cannot pass by having closed nothing. Mutation 37 |
+| `SEAM-25` | phase 2 (postponed to 8a, unwritten there) | `ExecutorSuite`'s "closing an owned executor emits exactly one shutdown event" — **the harness half, reassigned to phase 9 and stated as a correction to a committed record**, not a restatement of it (`P9-21`). The count is of shutdown EVENTS and not of sink writes, which review round 2 gave a double of its own (`P9-39`). Mutation 53 |
+| `SERDE-3` | phase 7a | The same `CodecSuite` assertion, re-derived rather than lifted (`P9-22`) |
+| `SERDE-9` | phase 7a | `CodecSuite`'s "a decode failure surfaces the SDK's serde type", all three clauses: it raises, the type is inside `Serde::Error`, and the original is chained — read through `Dexpace.each_cause` so `gates:cause_walk`'s allowlist stays at one entry. Mutation 38 |
+| `SSE-37` | phase 7b | `gates:serde_boundary`'s `PENDING` list asserted EMPTY, the clause 7b handed forward and could not assert while phase 7 was running. The task's abort branch is now driven by a fixture (`P9-30`) |
+| `ASYNC-3` | phase 8b | `ExecutorSuite`'s "cancelling a task blocked on a worker releases the worker within a bound", written so it genuinely FAILS and waived by ID, printed `waived (would fail): ASYNC-3`. The driver runs it UNWAIVED in a second test and requires the failure, so the waiver cannot outlive the limitation |
+| `ASYNC-15` | phase 8b | The close-latch and borrowed-executor assertions, which carry it beside `XCUT-13` and `XCUT-22` |
+| `ASYNC-16` | phase 8b | `ExecutorSuite`'s "close drains in-flight work and refuses new work", both halves |
+| `ASYNC-17` | phase 8b | `ExecutorSuite`'s "a resource-free implementation inherits a no-op close", whose subject is `Transport.async_over` over an inline executor and deliberately NOT the pool — handing the pool there would assert the opposite requirement |
+
+---
+
+## What was built
+
+Four suites, one aggregate, three repository gates, one generated level map, one coverage map and three
+first-party drivers.
+
+- **`Runner`** — the one place the five statuses are decided, with the rescue order load-bearing
+ (`Vacuous` and `Failure` are both `::StandardError` descendants, so the bare rescue is last) and a
+ FRESH subject per assertion. `TransportSuite` is deliberately not moved onto it (`P9-10`).
+- **`Report`** gains `.merge`, `#to_h`, `#blocking_vacuities`, `#accepted_vacuities`, `#unknown_ids`,
+ `#would_fail` and the MUST-level vacuity blocker; `#passed?` is false while a blocking vacuity stands.
+- **`Levels`** — 645 IDs generated from appendix C by `tools/requirement_levels.rb`, with a `--check`
+ that both exits are now proven against (`P9-31`).
+- **`SharedInstance.audit`** — `XCUT-11`'s structural predicate, R8's disjunction: frozen conforms on
+ `frozen?` alone, unfrozen only when every ivar is one the DRIVER declared.
+- **`InvariantSuite`** — 28 assertions over all 24 `XCUT` IDs in ten `private_constant` groups.
+- **`PackagingSuite`** — 8 assertions over `NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`.
+- **`CodecSuite`** — 2 portable seam assertions, re-derived (`P9-22`).
+- **`ExecutorSuite`** — 7 assertions in two groups, `ASYNC-3` among them and written to fail.
+- **`Aggregate`** — one `Report` over every suite, with a `PREAMBLE` that prints what a green run does
+ NOT prove on every render.
+- **Three gates** — `gates:cause_walk`, `gates:bounded_map`, `gates:seam_names`, over
+ `tools/ast_scan.rb`'s `RubyVM::AbstractSyntaxTree` walker. Twenty-one blocking gates in all.
+- **`APPENDIX_B.md`** — the 61-row coverage map, generated and hand-annotated.
+- **Three drivers** — `core/cross_cutting_invariants_test.rb` (the ONE file this phase adds under
+ `gems/dexpace-core/`, and the one place `R6`'s file list bends; `P9-40`),
+ `serde_json/conformance_test.rb`, `async_thread/conformance_test.rb`.
+
+---
+
+## Guards run red
+
+The reviewer's mutation list could not be recovered verbatim after this session's context was compacted
+(`P9-32`), so the set below was re-derived from the design's testing strategy and the plan's own
+"non-conforming double first" rule. **It was NOT one mutation per assertion, and saying so was this
+document's own error**: review round 2 measured that `XCUT-2`, `XCUT-6`, `XCUT-7` and `XCUT-10` had no
+row in this table at all and `XCUT-1` only subject rows, and review round 3's seven (rows 55–61) are
+what close that. **Sixty-two mutations, every one red** — forty-four at implementation, seven in
+**review round 1** (rows 46–52), two in **review round 2** (rows 53–54), seven in **review round 3**
+(rows 55–61) and two in **review round 4** (rows 62–63) — run one at a time through a harness that
+applies the edit, runs the owning suite, captures the first failure and restores the file, on
+**4.0.6** (row 52 on **3.2.11**, the row whose parser the fact differs on). The table below numbers
+sixty-three slots because two are placeholders for a mutation that was re-cut rather than a mutation of
+their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1; rows 55–61 and row 62
+neutralise one of this phase's OWN assertions whole rather than mutating a subject — the only shape
+that proves a committed double can report `:failed` — and row 63 removes a single `Check` from one, so
+`XCUT-18`'s name/value asymmetry has a control of its own. All eleven are recorded as what
+they were, a gap in this phase's own guards and not a gap in the code those guards watch.
+
+Six shapes were re-cut: one spun forever and was killed rather than counted (a finding in its own
+right, routed below), and five went green; **two of those five were not bad mutations but real
+non-discrimination in this phase's own assertions**, and both were repaired before the mutation was
+re-run — `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers could
+finish one at a time. Those two repairs are `P9-28` and `P9-29`, and they are the reason the mutation
+pass is worth its cost.
+
+| # | Mutation | Caught by (first failure) | Rows |
+|---|---|---|---|
+| 1 | `Headers#initialize` aliases the caller's Hash instead of `Model.own` | `XCUT-15: a model changed when a collection passed into it was mutated` | `XCUT-15` |
+| 2 | `Closeable#close` runs `#release` on every call | `XCUT-13: close ran its release more than once (expected 1, got 2)` | `XCUT-13` |
+| 3 | `Closeable#close` releases regardless of `owned:` | `XCUT-22: the SDK closed a resource it did not create` | `XCUT-22` |
+| 4 | `HeaderSyntax.validate_name!` admits HTAB | `XCUT-18: a header NAME containing HTAB was accepted` | `XCUT-18` |
+| 5 | `HeaderSyntax.validate_outbound_value!` refuses HTAB | `XCUT-18: an outbound VALUE containing HTAB was rejected` | `XCUT-18` |
+| 6 | `BoundedMap#set` evicts once instead of draining | `XCUT-14: one insert into an over-cap map evicted once instead of draining back to the cap` | `XCUT-14` |
+| 7 | `BoundedMap#set` has no cap at all | `XCUT-14: an insert burst pushed the map past its cap` | `XCUT-14` |
+| 8 | `Body.buffer_bounded` multiplies its cap | `XCUT-24: an error-body snapshot materialised an unbounded payload` | `XCUT-24` |
+| 9 | `Redactor`'s authority builder forwards the userinfo | `XCUT-19: "pw" survived URL redaction` | `XCUT-19` |
+| 10 | `Redactor#header_name?` is default-ALLOW | `XCUT-19: the header allow-list is not default-deny` | `XCUT-19` |
+| 11 | `Instrumentation.contain` re-raises after reporting | `RuntimeError: a sink exploded` (an `:error`, which is the point: it reached the caller) | `XCUT-20` |
+| 12 | `Policy.cancellation?` stops walking the cause chain | `XCUT-1: a cancellation wrapped in a retryable transport error was not recognised` | `XCUT-1` |
+| 13 | `Policy.throwable_retryable?` answers true for a cancellation | `XCUT-1: a cancellation classified retryable` | `XCUT-1` |
+| 14 | *(superseded — see 22)* | | |
+| 15 | `Reissue.strip` moves the `Authorization` removal behind the cross-origin test | `XCUT-17: Authorization survived a SAME-ORIGIN re-issue (clause a)` | `XCUT-17` |
+| 16 | `Reissue.strip` drops the `Cookie` removal | `XCUT-17: cookie survived a CROSS-ORIGIN re-issue (clause b)` | `XCUT-17` |
+| 17 | `Location` clears the userinfo with `= nil`, the documented silent no-op | `XCUT-17: userinfo in the Location survived the re-issue (clause c)` | `XCUT-17` |
+| 18 | `Reissue.downgrade!` never raises | `XCUT-17: an HTTPS-to-HTTP downgrade was followed without opt-in (clause d)` | `XCUT-17` |
+| 19 | `Auth::Step#enforce_https!` raises nothing | `XCUT-16: a credential was stamped over a plaintext transport` | `XCUT-16` |
+| 20 | `Auth::Step` drops the cross-origin suppression | `XCUT-16: a credential-FREE cross-origin re-issue was refused by the HTTPS guard` | `XCUT-16` |
+| 21 | `DigestHandler` draws from `::SecureRandom` instead of the injected source | `XCUT-21: the handler drew no bytes from the injected source` | `XCUT-21` |
+| 21b | `Registry#install` becomes a no-op | `XCUT-23: an auto-discovered implementation beat an explicit install` | `XCUT-23` |
+| 22 | `Clock#sleep` never subscribes the token | `XCUT-3: a pending inter-attempt wait was not released by a cancellation` | `XCUT-3` |
+| 23 | `BearerStamper#refresh!` fetches outside the lock | `XCUT-12: a token refresh was not single-flight: every concurrent caller fetched` | `XCUT-12` |
+| 24 | `Dexpace.each_cause` drops its visited test | `XCUT-9: the cause walk did not terminate after visiting the cycle once` | `XCUT-9` |
+| 25 | `Registry#sole_key` picks the first of several candidates | `XCUT-23: two registered candidates with no explicit install did not fail loudly` | `XCUT-23` |
+| 26 | *(the zero-candidate branch is the same method; covered by 25 and 21b)* | | |
+| 27 | `Runner#one` rescues `StandardError` before `Vacuous` | `Expected: :vacuous / Actual: :error` | `NFR-17` |
+| 28 | `Runner#waived?` matches the assertion NAME | `the preamble, the acceptances and the would-fail declarations reach the Report` | `NFR-17` |
+| 29 | `Report#passed?` drops the blocking-vacuity term | `an acceptance naming one of two MUST ids still blocks, because accepted? is all?` | `NFR-17` |
+| 30 | `Report#accepted?` becomes `any?` | the same | `NFR-17` |
+| 31 | `Report#validate_citation!` admits a blank citation | `ArgumentError expected but nothing was raised` | `NFR-17` |
+| 32 | `Report#unknown_ids` always answers empty | `Expected: ["XCUT-99"]` | `NFR-17` |
+| 33 | `Levels.must?` counts an unknown ID as a MUST | `an unknown id never blocks` | `NFR-17` |
+| 34 | `SharedInstance.audit` drops the frozen half of the disjunction | `a frozen instance holding state conforms on frozen? alone` (raised a `Failure`) | `XCUT-11` |
+| 35 | `SharedInstance.audit` reads the declaration off the audited object | `a subject declaring its own per-call state as exempt is not believed` | `XCUT-11` |
+| 36 | `Closeable#close` unlatched, seen through the pool's shutdown event | `XCUT-13, ASYNC-15: close shut the executor more than once` | `XCUT-13`, `ASYNC-15` |
+| 37 | `Codec#dump_to` closes the sink it was handed | `SEAM-20, SERDE-3: the codec closed a caller-supplied sink` | `SEAM-20`, `SERDE-3` |
+| 38 | `Codec#load` lets a bare `NoMethodError` escape | `SERDE-9: a failure outside the SDK's serde hierarchy escaped the seam` | `SERDE-9` |
+| 39 | `Runner.run` builds one subject and shares it across the run | `the subject block is called once per assertion / Expected: 2, Actual: 1` | `NFR-17` |
+| 40 | `Bridge::AsyncOver` owns and closes its executor | `XCUT-22, ASYNC-15: the SDK shut down an executor it borrowed` | `XCUT-22`, `ASYNC-15` |
+| 41 | `PackagingSuite::Surface::LEAK` matches nothing | `a signature naming an async-framework type fails NFR-11 / Expected: :failed` | `NFR-11` |
+| 42 | `Aggregate.by_requirement_id` defaults a missing result to `:passed` | `by_requirement_id reads each suite's DECLARED assertions, not a report's results` | `NFR-17` |
+| 43 | one row deleted from `APPENDIX_B.md` | `every row's ID column equals the set parsed from that item's own text` | `NFR-17` |
+| 44 | `Sharing` skips the driver's declared shared instances | `a shared instance the driver declares is audited too` | `XCUT-11` |
+| 45 | `AstScan.parse` leaves `$VERBOSE` armed | `AstScan.parse's $VERBOSE window did not hold` | `XCUT-9`, `XCUT-14`, `SEAM-2` |
+| 46 | `Shutdown#check_release`'s expired entry pop raises nothing | `[Vacuous] exception expected, not Failure: a worker blocked in an uninterruptible call was not released after cancel` | `ASYNC-3` |
+| 47 | `check_posters_returned` keeps its bounded join and drops its `Check` | `Expected /did not return/ to match "work posted from many threads was lost or duplicated"` | `SEAM-12`, `XCUT-11` |
+| 48 | `check_all_returned` keeps its bounded join and drops its `Check` | `Expected /did not return on every thread/ to match "a shared step crossed one call's request into another's"` | `XCUT-11` |
+| 49 | `Dependencies#single_version_source` stops raising its vacuity | `with no single source named, NFR-14 is vacuous rather than a pass / Expected: :vacuous, Actual: :passed` | `NFR-14` |
+| 50 | `PackagingCase::DEFAULT_RESOLVE` drops `::Gem::LoadError` | `Gem::MissingSpecError: Could not find 'dexpace-no-such-gem-…'` — 1 error, not 8 vacuities | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15` |
+| 51 | `RequirementLevels::ROW` loses its nineteen-prefix restriction | `{"HTTP-7"…, "RFC-7235" => :must, "ISO-8601" => :should, …}` against a synthetic table | `NFR-17` |
+| 52 | the duplicated-key capture is taken INSIDE the `$VERBOSE` window | `a duplicated key warns whatever $VERBOSE is` (3.2.11) | `XCUT-9`, `XCUT-14`, `SEAM-2` |
+| 53 | `ExecutorCase#shutdowns` counts every recorder payload instead of matching `Keys::EVENT` | `XCUT-13 => :failed, XCUT-22 => :failed, SEAM-25 => :failed` against the chatty-but-conforming pool | `SEAM-25`, `XCUT-13`, `XCUT-22` |
+| 54 | `Models#only_closes_what_it_created`'s second `Check` takes a literal `true` | `a holder that tears a borrowed resource down without closing it fails XCUT-22 / Expected: [:failed], Actual: [:passed]` | `XCUT-22` |
+| 55 | `Concurrency#cancellation_is_terminal` neutralised WHOLE — `return nil if subject` as the body's first line, so no local is orphaned and the `-w` hook cannot mask the result | both new `XCUT-1` doubles: `a cancellation classifier that reads only the outermost error fails XCUT-1` and `a classifier that calls a cancellation retryable fails XCUT-1`, `Expected: [:failed]` | `XCUT-1` |
+| 56 | `Concurrency#timeout_is_retryable_and_distinct` neutralised whole | both new `XCUT-2` doubles: the EXACT-type match and the message-matching classifier, `Expected: [:failed]` | `XCUT-2` |
+| 57 | `Taxonomy#open_capability_query` neutralised whole | both new `XCUT-6` doubles: the outermost-only query and the concrete-type match, `Expected: [:failed]` | `XCUT-6` |
+| 58 | `Taxonomy#configurable_status_set_is_authoritative` neutralised whole | both new `XCUT-7` doubles: the ANDed set and the wrong default, `Expected: [:failed]` | `XCUT-7` |
+| 59 | `Classification#retry_safety_is_uniform` neutralised whole | both new `XCUT-10` doubles: the re-sent bare POST and the failure parameter, `Expected: [:failed]` | `XCUT-10` |
+| 60 | `Security#redaction_is_default_deny` neutralised whole | both new `XCUT-19` doubles: the userinfo-forwarding redactor and the default-ALLOW header list, `Expected: [:failed]` | `XCUT-19` |
+| 61 | `Memory#capped_non_consuming_preview` neutralised whole | the new `XCUT-24` double: a snapshot that multiplies its cap, `Expected: [:failed]` | `XCUT-24` |
+| 62 | `HeaderSyntax#header_syntax_validation` neutralised whole — `XCUT-18`'s MODEL layer, the fourth survivor of round 3's sweep and the one no document named | both new model-layer doubles: `a validator that refuses nothing fails XCUT-18's model-layer assertion` and `an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause`, `Expected: [:failed, :vacuous]` | `XCUT-18` |
+| 63 | `HeaderSyntax#check_values` drops its HTAB-ACCEPTED clause, leaving the outbound-value rule a subset of the name rule | the symmetric double ALONE: `an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause`. The permissive double still fails on the NAME clauses, which is why the asymmetry needs a second one | `XCUT-18` |
+
+One more shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s
+`return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with
+no progress, and it ran for six minutes before it was stopped. That is not a guard going red; it is the
+finding routed below, and the two mutations that replaced it — 21b and 25 — are what prove `XCUT-23`.
+
+A second shape is recorded the same way, and for the same reason. Restoring `Shutdown#check_release`'s
+entry pop to the unbounded `transport.entered.pop` review round 0 found there — row 46's mutation taken
+one step further — leaves the refusing-executor test with nothing to return to: the run was still
+parked when `timeout 90` killed it, and the same removal at the two join sites is caught instead, by
+Ruby's own deadlock detector, as `fatal: No live threads left. Deadlock?`. That asymmetry is why rows
+47 and 48 drop the `Check` and keep the bounded join: a mutation the interpreter aborts proves the join
+runs, not that an expired one is reported. The hang itself is the defect `P9-34` fixes, demonstrated; it
+is not a guard going red and it is not counted among the sixty-two.
+
+Beside the mutations, the three new gates are each driven against a deliberately failing **fixture
+workspace** through `DEXPACE_GATE_ROOT` (`test/fixtures/gates/invariants/workspace/`), and
+`gates:serde_boundary`'s abort branch against a `RUBYOPT` prelude fixture. Those four routes did not
+exist when the mutation pass began and the first of them found a real defect — `P9-27`.
+
+---
+
+## Audit groups run
+
+The phase-start pair first, at implementation on 2026-09-23: `--origin note --brief` and
+`--section conflicts --brief`, the latter returning every one of the six harvested conflicts
+`[overridden by notes/…]` and none open. `--req` was run per task against the IDs each task names.
+
+**This phase's own audit group did not exist and was added to the table** in
+`.claude/skills/knowledge-lookup/SKILL.md`, because an audit whose group is not written down cannot be
+repeated: **Cross-cutting invariants and the quality bar** —
+`--topic cross-cutting-invariants,tooling-and-quality-gates,resource-management --section rules --brief`
+and `--prefix XCUT,NFR --section rules --brief`. Run on 2026-09-23: **41 entries across 12 topic files**,
+and `--gaps XCUT,NFR` answers **0 of 41** — every one of this phase's IDs has a substantive corpus entry,
+none roll-up only, so nothing here had to be read out of the specification instead.
+
+| Group | Result at implementation |
+|---|---|
+| Cross-cutting invariants and the quality bar | The new row above. Two of its entries are this phase's own, filed at design time — `cross-cutting-invariants/89eb6533`'s audit consequence and the AST-gates entry — and both were re-read in full before the code they describe was written; one of the two is CORRECTED by execution, in a new entry rather than an edit (`gates:drain_loop` was not built) |
+| Public API surface | Every new group module is a `private_constant`, so `tools/surface.rb`'s `Module#constants(false)` walk does not see it — 8a's `TransportSuite` precedent. Each suite's public surface is exactly `assertions`, `run` and `PREAMBLE`; two leaks found by reading the regenerated manifest row by row were closed rather than accepted (`P9-25`) |
+| Gem layout, zero-dependency core | `dexpace-conformance` still declares `dexpace-core` alone; the new files add no require outside the allowlist; `gates:clean_bundle` green on every row |
+| RBS / Steep typing | Every new `lib/` file has a `sig/` mirror, the `private_constant`s carrying `hooks.rbs`'s comment; both targets green with no relaxation added. One signature is deliberately INCOMPLETE and says so: `invariant_suite/credentials.rbs` omits `include ::Random::Formatter`, because `tools/rbs_surface.rb`'s `STDLIB_ALLOWED` does not carry `Random` and widening a gate's allowlist for a test double is lowering the gate (`P9-26`) |
+| Minitest conventions | Every suite subclasses `DexpaceTestCase` or `GateCase`; no `.stub`; mutable fixtures built fresh per test. No `sleep` anywhere in the suites' own logic or in any assertion — every wait there is a queue pop with a timeout, a bounded join or a bounded `Thread.pass` spin on `Thread#status`. **One deliberate `Kernel.sleep` exists, in a non-conforming DOUBLE**: `invariant_suite_test.rb`'s `BlockingCloseSeam#close` sleeps `0.6` on its first close, past `XCUT-13`'s half-second bound, because a double whose whole job is to block past a bound has no other way to do it — and without it that clause would pass against every subject the suite can build |
+| Fiber scheduler, thread safety | The two-fibers-on-one-thread assertion is the shape 8b handed forward; the barriers added in `P9-28` and `P9-29` use `Thread#status` and never a sleep |
+| Observability, configuration and redaction | `XCUT-19`'s and `XCUT-20`'s assertions are scoped to what 5b and 5c own and are not widened to a foreign callback |
+
+---
+
+## Deviations from the plan
+
+Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. The
+corresponding ledger rows are `P9-21`–`P9-42` in the design's As-built addendum.
+
+1. **`dexpace-serde-json`'s and `dexpace-core`'s existing files are byte-identical** — the plan's Task 10
+ removes `assert_closes_nothing`'s and `assert_failure_model`'s portable halves from 7a's
+ `serde_seam_assertions.rb` and drops two calls from its driver. Nothing was removed: the two
+ `CodecSuite` assertions are RE-DERIVED from the requirement text, so `SEAM-21`'s type-token clause and
+ the encode half of `SERDE-9` — both of which live inside the bodies a lift would have moved — stay
+ alive in 7a's suite. `P9-22`.
+2. **`MinitestDriver` is not used by any of the three drivers.** 8a wrote it around `TransportCase` and
+ `R6` keeps phase 9 out of phase 8's file. Each driver carries its own six-line loop instead; the
+ residue — two driver loops in one repository — is stated rather than hidden. `P9-23`.
+3. **`test/support/gate_warning_capture.rb` deliberately does not require `dexpace_test_case`.** The plan
+ said to, so the two prepends land in order. Measured: `test/gates/` runs on `GateCase`, which does not
+ install `FatalWarnings`, and arming it from a support file armed it for the whole `test:gates` process
+ — where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always
+ reached `Warning.warn` harmlessly. Four pre-existing gate tests went red. The file ships the RECORDER
+ and not the raiser, and the scanner's warning property is proven by recording. `P9-24`.
+4. **The bounded-map allowlist was re-adjudicated against as-built paths, and the plan's one expected
+ true positive is a false positive.** The plan's Task 16 Step 3 expects `gates:bounded_map` to report
+ `async_http/clients.rb`'s `@by_origin` as an uncapped cache. The as-built name is `@by_key` and it IS
+ capped: `Clients#fetch` calls `#drain` inside the same `@mutex.synchronize` as the insert, and `#drain`
+ is a loop — `evicted << @by_key.delete(@by_key.keys.first) while @by_key.size > MAX_ORIGINS`, closed
+ reactors first — which is `XCUT-14`'s drain clause exactly. Verified by reading the file on
+ 2026-09-23. So the gate is GREEN, `XCUT-14`'s row is ✅ rather than ⏳, no phase-10 inbound bullet is
+ filed for it and no `docs/first-release.md` blocker is opened — and the standing blocker that waited
+ on this verification is ticked rather than a new one filed. The allowlist that remains after the
+ re-adjudication is six entries, each with its written reason in `tools/invariant_gates.rb`, and the
+ integrity test asserts that every entry names a live file the scan still reports at that site — the
+ clause whose absence let the stale `configuration.rb` key survive a whole adjudication. `P9-37`.
+5. **Two assertions this phase wrote did not discriminate, and both were repaired.** `XCUT-3`'s cancel
+ could be issued before the waiter entered the wait, and `XCUT-12`'s racers could finish serially. Both
+ now assert a parking barrier as a `Check` of its own, so a run that could not have discriminated
+ reports `:failed` and never `:passed`. Found by the mutation pass. `P9-28`, `P9-29`.
+6. **The three new gates' `DEXPACE_GATE_ROOT` route was broken and is fixed.** The tasks handed the
+ scanner paths made relative to the gate root, which it then opened relative to the process's CWD — the
+ same directory only when the variable is unset. Under a fixture root every open raised `Errno::ENOENT`,
+ so the three gates could never have been shown to reject anything. They now scan absolute paths under
+ the root, join both allowlists to the same root, and make the message relative afterwards; a fixture
+ workspace and four tests keep it fixed. This is a defect in this phase's own code and is therefore
+ fixed rather than routed. `P9-27`.
+7. **`gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test.** `PENDING` is a
+ frozen constant with no root or environment that can make the task see a non-empty one, and
+ `RequirementLevels::TARGET` is fixed — so a mutation deleting the abort, and one making `--check`
+ always exit 0, both left every test green. A `RUBYOPT` prelude fixture drives the first; an optional
+ path argument on `--check` drives the second. `P9-30`, `P9-31`.
+8. **The reviewer's mutation list is not quoted here.** It was in this run's opening brief and did not
+ survive the session's context compaction. The forty-four mutations run at implementation were
+ re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule;
+ the count exceeds the
+ thirty-five the brief named as a minimum, but the SET is this phase's and not the reviewer's, and a
+ reviewer re-running the original list should expect to find it covered rather than assume it. `P9-32`.
+9. **`SEAM-25`'s harness half is recorded as a correction to a committed record.** 8b's design said the
+ harness half was 8a's; 8a wrote no executor suite. Phase 9 writes it, and says so out loud rather than
+ restating the earlier record as though it had been right. `P9-21`.
+
+### Review round 1, 2026-09-24
+
+Two further departures, both repairs to **this phase's own gem** — `R6` bars phase 9 from repairing what
+it AUDITS, and `dexpace-conformance`'s new code is what it wrote, the same standing `P9-27` has.
+
+10. **Three waits in the new suites carried no bound, and one of them hung.** `Shutdown#check_release`
+ opened with an unbounded `transport.entered.pop`; against an executor that REFUSES the post —
+ `ASYNC-2`'s saturated queue, or a closed pool — nothing is ever pushed, the poster rescues the
+ refusal, and the run never returns, with the `ensure` that frees the gate and closes the pool never
+ reached. Demonstrated, not argued: with the bound removed the refusing-executor test was still
+ parked when `timeout 90` killed it, and the reviewer's own probe — the same assertion on a thread
+ joined at eight seconds — reported `:HUNG` where it now answers
+ `[:vacuous, "the executor did not start the posted unit within 1.0s …"]`. The two `.each(&:join)`
+ sites — `concurrent_post` and
+ `drive_threads` — had the same shape against a subject whose submission or whose shared call never
+ returns. All three now carry a bound, 8a's own rule for this gem
+ (`await_closed_connection`'s `timeout:`), and the expired entry wait is `:vacuous` with its reason
+ rather than `:failed`, because with no unit started there is no blocking worker to cancel.
+ Rows 46–48. `P9-34`.
+11. **`PackagingCase::DEFAULT_RESOLVE` never caught the error it exists to catch**, and writing the test
+ review round 0 asked for is what found it: every case in `packaging_suite_test.rb` supplied its own
+ `resolve:`, so the SHIPPED lambda had never run. `Gem::MissingSpecError` descends from
+ `Gem::LoadError < LoadError < ScriptError` and is **not** a `StandardError`, so `rescue
+ ::StandardError` let it past — and past `Runner`'s bare rescue too, aborting the whole run where the
+ contract is one `:vacuous` naming the absent unit. Row 50. `P9-35`.
+
+### Review round 2, 2026-09-24
+
+Two further departures, both repairs to guards this phase wrote, found by re-running two shapes from
+the brief's own minimum mutation list that review round 1 measured as SURVIVING.
+
+12. **`XCUT-22`'s second clause could not fail.** `InvariantSuite::Models#only_closes_what_it_created`
+ makes two `Check`s against one `Borrowed` double: it was not closed, and it still works. `usable?`
+ was `!@closed` — the exact negation of the `closed?` the first `Check` already reads — so no subject
+ the suite can build separated them, and replacing the second `Check`'s condition with a literal
+ `true` left the whole gem's suite green. The comment above it and this checklist's own `XCUT-22` row
+ both claimed the opposite. `Borrowed` now carries the other route the requirement implies: `#finish`
+ is the adapter-side teardown reached without going through `#close` — `Net::HTTP` spells it exactly
+ that — and `usable?` reads both flags. `TearingSeam` drives it. Row 54. `P9-38`.
+13. **`SEAM-25` was "one sink write" as tested, not "one shutdown EVENT".** `ExecutorCase#shutdowns`
+ matches the payload's `Keys::EVENT` entry, and every double in `executor_suite_test.rb` emitted the
+ shutdown payload and nothing else, so replacing that match with a bare `entries.count` left the
+ file, the pool driver and the whole `rake test:gems` green — while four assertions read that count.
+ `ChattyPool` logs one other event payload and one bare String message at construction, which is
+ conforming and which nothing in the SPI forbids; the mutation now reddens `XCUT-13`, `XCUT-22` and
+ `SEAM-25` together. No code changed: the code was right and only the guard was missing. Row 53.
+ `P9-39`.
+
+### Review round 3, 2026-09-24
+
+Two departures, one of them a document repair and the other the same class of defect rounds 1 and 2
+found: a guard this phase wrote that could not go red.
+
+14. **`R6`'s one sanctioned exception was recorded nowhere, and two sentences could not both hold.**
+ The driver for `InvariantSuite` is `gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb`
+ — one ADDED file, no existing core file modified, renamed or deleted — and that is what design
+ `P9-9` means by "the driver is `dexpace-core`'s own suite and knows which ivar is which". The
+ design's Module layout says the opposite in as many words ("no file in `gems/dexpace-core/` at
+ all"), and this checklist's and the roadmap's boundary sentences enumerated the trees phase 9
+ changed without naming it. All three now name the file and the narrow bend it is, and `P9-40`
+ records the contradiction by pointing at both sentences. Nothing in the tree changed: the boundary
+ was always one added test file, and a reader could only learn that by running
+ `git diff --name-status`. `P9-40`.
+15. **Seven `XCUT` assertions had no committed non-conforming double, and five could be neutralised
+ WHOLE with the suite green.** `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19` and
+ `XCUT-24`: nothing in the repository drove a subject that made any clause of the first five fail,
+ so each reported `:passed` because it could not report anything else, and the evidence paragraph
+ above and the Guards preamble both claimed otherwise. `invariant_suite_core_test.rb`'s own header
+ made it worse by naming `XCUT-6`, `XCUT-7` and `XCUT-10` among the IDs it drove. Thirteen defective
+ stand-in cores now drive all seven, in the shape that file already used — a patched `Resilience`
+ whose `const_missing` falls through, so one test changes one subject. Measured: neutralising each
+ of the seven assertions whole and warning-free reddens exactly the new tests for it, thirteen
+ across the seven (rows 55–61). No first-party code changed: the assertions were right and only the
+ negative controls were missing. `P9-41`.
+
+### Review round 4, 2026-09-24
+
+One departure, and it is the same class narrowed by one row: a guard this phase wrote that could not go
+red, found by counting assertions where this document counted rows.
+
+16. **`XCUT-18`'s model-layer assertion had no committed non-conforming double, and no document named
+ it.** Review round 3's sweep neutralised all forty-five assertion bodies in the four suites one at a
+ time; four survived, and three were accounted for — `XCUT-3`'s and `XCUT-12`'s, which this checklist
+ names, and a private clause helper whose parent assertion is caught. The fourth,
+ `HeaderSyntax#header_syntax_validation`, could be neutralised whole with the whole of
+ `rake test:gems` green: both of its committed tests pin the model assertion `:passed` and drive the
+ call-site one. The evidence paragraph above could not see it, because it counted **rows** and
+ `XCUT-18` carries two assertions — the row was covered on one of them. Two defective stand-in cores
+ now drive it: a permissive validator that refuses nothing, and one that applies the NAME rule to
+ outbound values as well, so the asymmetry that IS the requirement has a control of its own rather
+ than resting on the real subject. Measured: neutralising the assertion whole reddens both (row 62),
+ and deleting the HTAB-accepted `Check` alone reddens the second (row 63). No first-party code
+ changed. `P9-42`.
+
+---
+
+## Findings routed
+
+Nothing below is repaired here. Each is routed to its owner with its evidence, per design `R6`.
+
+| Finding | Evidence | Routed to |
+|---|---|---|
+| **307 of 307 shipped `.rbs` files carry no SPDX header**, in all six gems — `NFR-13`'s conformance clause is "scan ALL source files" and `sig/` ships inside every gem | The aggregate run's one failure, 2026-09-23; `grep -rl "SPDX-License-Identifier" gems --include='*.rbs'` answers 0 of 307 | **Already owned**: phase 10's design addendum `A8` (`gates:spdx_rbs`) and its inbound row 8, which name this very assertion. Verified still owned on 2026-09-23 and NOT re-recorded |
+| `PackagingCase`'s default gem-name → constant-path rule cannot produce `Dexpace` for `dexpace-core` or an acronym-cased leaf (`NetHTTP`, `AsyncHTTP`, `JSON`), so four of six gems need the `constants:` override a driver supplies. A driver that omits it gets a `:vacuous` with an actionable message, never a wrong answer | The aggregate run's first pass reported `Dexpace::Core is not loaded` | phase 10's inbound list (dated 2026-09-23) — a defaulting rule that covers the port's own six is a repair, not a report |
+| `Registry#resolve`'s discovery loop spins forever when `@state.resolved` is set but a candidate remains registrable — reachable only by a code change, but the loop has no bound at all | Found while cutting a mutation: `return hand_out(resolved) if resolved && @state.factories.empty?` turned `#resolve` into an unbounded `loop do` with no progress. The shipped code is correct; the LOOP's lack of a bound is the finding | phase 10's inbound list (dated 2026-09-23) |
+| `XCUT-12`'s fiber-scheduler form is unasserted: the requirement's "wait-free hot-path read" and single-flight clauses are proven for threads only, because a credential path under a reactor is a composition no first-party suite assembles | `invariant_suite/resolution.rb`'s own comment; the assertion names the thread form explicitly | phase 10's inbound list (dated 2026-09-23) |
+| `XCUT-9`'s stated residue: a collect-then-yield cause walk never returns a step to count, and a depth cap of exactly three passes the cycle assertion. `gates:cause_walk` is the second line and proves neither absent | `invariant_suite/classification.rb`'s comment, written before the assertion | phase 10's inbound list (dated 2026-09-23) |
+| 7a's `SEAM-21` evidence lives inside `assert_closes_nothing`'s body and has no assertion of its own; the re-derivation left it there deliberately, but it is undeclared in any ID-keyed map | `P9-22`; `APPENDIX_B.md`'s `B.3` rows | phase 10's inbound list (dated 2026-09-23) |
+| `tools/surface.rb` cannot see a `private_constant` module, so a group module that became public would be invisible to `gates:surface_snapshot` until its methods leaked | Found by reading the regenerated manifest row by row (`P9-25`): two real leaks were present and were closed | phase 10's inbound list (dated 2026-09-23) |
+
+---
+
+## Postponed work
+
+Everything the design postponed is VERIFIED still owned rather than re-recorded, per the phase workflow.
+The five entries in the design's "Work phase 9 postponed, and who owns it now" table stand as written;
+their owners — `docs/first-release.md` § Post-release triggers and § Release path — were re-read on
+2026-09-23 and each names the entry it was given.
+
+`NFR-16` is the one requirement row this phase leaves ⏳, and its owner is the `NFR-12`/`NFR-16` entry
+under § Release path. `NFR-8` and `NFR-9` are N/A with their reasons above and are not deferred: there is
+nothing to pick up.
diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md
index c237000..1c790a3 100644
--- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md
+++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md
@@ -1075,6 +1075,45 @@ Each row is consolidated into design §10 and audited by `docs/deviations.md`.
| P9-9 | `XCUT-11`'s audit predicate permits two named kinds of mutable state on a shared instance — a `Thread::Mutex` with the state it guards, and `Closeable`'s `@closed` latch — and **the driver declares them, never the audited object** | `XCUT-11`; `cross-cutting-invariants/89eb6533` | A *frozen and no ivars* predicate would condemn every closeable component in the SDK and the exact latch shape the corpus prescribes. Reading the declaration off the subject inverts the requirement in both directions — measured: a conforming latch-plus-mutex object **fails**, because no phase committed to such a method and `R6` forbids adding one, while the identical per-call-state bug **passes** by declaring its own ivar exempt. The driver is `dexpace-core`'s own suite and knows which ivar is which |
| P9-10 | `Runner` is **new shared infrastructure inside a gem phase 8 owns**, and `TransportSuite` is deliberately not moved onto it | design §9.3; the conformance assertion protocol; `R6` | Four suites would otherwise carry four copies of one status loop, which is §11.12's "four reference sync/async drifts" reappearing inside the port's own suite. Not moving `TransportSuite` leaves **two status-deciding paths in one gem**, and that is the price of `R6`: phase 8 owns that file, and a phase that refactors another phase's shipped code while claiming only to report is the boundary failing quietly. The residue is real — a future change to the five statuses must be made twice — and is stated rather than left to be noticed |
+### As built, 2026-09-23
+
+The ten rows above stand as decided; `R1`–`R8` were built as written, with `R3`'s vacuity blocker, `R5`'s
+`waived (would fail)` rendering and `R8`'s disjunction all shipping in the shapes this document fixed.
+This phase was cut from `main` at `582e33a`, which holds every phase through 8. Execution added the rows
+below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left free and nothing is
+renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the
+rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this
+document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, `P9-36`
+through `P9-39` in **review round 2**, `P9-40` and `P9-41` in **review round 3**, and `P9-42` in
+**review round 4**, all on the same day; each is marked as such in its own row, and
+nothing earlier is rewritten except `P9-32`'s count, which was wrong as written and is re-stated in
+every round since, for the same reason.
+
+| # | Deviation | Requirement / document | Why |
+|---|---|---|---|
+| P9-21 | `SEAM-25`'s harness half is recorded as a **correction** to a committed record, not a restatement of one | `SEAM-25`; 8b's design | 8b's design said the harness half was 8a's and that 8b would hand it the shape; 8a wrote the protocol, the wire fixture and the transport suite and **no executor suite**. So the harness half was unwritten after phase 8. Phase 9 writes it and says so out loud, which is the discipline phase 4b failed when it corrected its charter while claiming to restate it |
+| P9-22 | `CodecSuite`'s two assertions are **re-derived from the requirement text**, and `dexpace-serde-json`'s own files are left byte-identical | `SEAM-20`, `SERDE-3`, `SERDE-9`; plan Task 10 | The plan's lift would have moved `assert_closes_nothing`, whose body is the only evidence `SEAM-21` has in that suite, and `assert_failure_model`, which carries `SERDE-10` and the ENCODE half of `SERDE-9` too. A lift taking the portable halves would have dropped three properties on its way out. The relationship is `TransportSuite`'s to the net_http suite's instead: a portable assertion beside a gem-local one, both alive |
+| P9-23 | None of the three first-party drivers uses 8a's `MinitestDriver`; each carries its own six-line generated-test loop | `R6`; 8a's `MinitestDriver` | `MinitestDriver#conformance` builds a `TransportCase` for every assertion, and widening it would change an interface phase 8 owns. The residue — two driver loops in one repository — is the price of `R6` and is stated in each driver's header rather than hidden |
+| P9-24 | `test/support/gate_warning_capture.rb` deliberately does **not** require `dexpace_test_case`, and the scanner's warning property is proven by RECORDING rather than by arming the raiser | `NFR-6`; plan Task 1 | The plan said to require it so the two prepends land in order. Measured: `test/gates/` is the one suite whose base is `GateCase`, which does not install `FatalWarnings`, and arming it from a support file armed it for the whole `test:gates` process — where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always reached `Warning.warn` harmlessly. Four pre-existing gate tests went red. What the file needs is the recorder, not the raiser |
+| P9-25 | Two public-surface leaks were found by reading the regenerated manifest row by row and were **closed rather than accepted** | `NFR-4`; `R6` | `Aggregate#add_rows` was public (an `@api private` tag is not privacy), and `CodecSuite`'s two assertion factories were public where every other suite's are in a `private_constant` group. Both are this phase's own code, so both are fixed: every suite's public surface is now exactly `assertions`, `run` and `PREAMBLE`. `tools/surface.rb` walks `Module#constants(false)` and cannot see a `private_constant` module at all, which is why reading the manifest was the only way to find them |
+| P9-26 | `invariant_suite/credentials.rbs` deliberately omits `include ::Random::Formatter`, and says so in the file | `NFR-11`; `gates:rbs_surface` | The `Recorder` double includes `Random::Formatter` so one `#bytes` override records every draw. `tools/rbs_surface.rb`'s `STDLIB_ALLOWED` does not carry `Random`, and widening a gate's allowlist to accommodate a test double is lowering the gate. The signature is incomplete by one line, the reason is in the file, and Steep is green over it |
+| P9-27 | The three new gates' `DEXPACE_GATE_ROOT` route was **broken on arrival and is fixed**, with a fixture workspace and four tests | `R4`; the repository's every-gate-has-a-failing-fixture rule | The tasks handed the scanner paths made relative to the gate root, which `AstScan.parse` then opened relative to the process's CWD — the same directory only when the variable is unset. Under a fixture root every open raised `Errno::ENOENT`, so the three gates could never have been shown to REJECT anything. They now scan absolute paths under the root, join both allowlists to the same root, and make the message relative afterwards. A defect in this phase's own code, so `R6`'s report-don't-repair rule does not apply to it |
+| P9-28 | `XCUT-3`'s assertion asserts that the waiter was **parked** when the cancel was issued, as a `Check` of its own | `XCUT-3` | Without the barrier the cancel could be issued before the waiter entered `Clock#sleep`, whose own entry check then raises — and a `Clock#sleep` that ignores the token entirely PASSED. Measured by mutation. The barrier is a bounded `Thread.pass` spin on `Thread#status`, never a sleep, and its result is checked rather than assumed, so a run that could not have discriminated reports `:failed` |
+| P9-29 | `XCUT-12`'s assertion asserts that **every** racer was parked before the coalesced fetch is released | `XCUT-12` | Without the barrier the first thread finished alone and the other fifteen took the cached-token path without ever racing, so a stamper fetching per caller PASSED. Measured by mutation. Under a conforming stamper one racer parks in `#fetch` and fifteen park on the credential's lock; under a non-conforming one all sixteen park in `#fetch` — both are `"sleep"`, so the barrier is the same for either and decides nothing by itself |
+| P9-30 | `tools/serde_boundary.rb`'s `pending` gains `list:`, **and** the task's abort branch gets a `RUBYOPT` prelude fixture | `SSE-37`; `R4` addendum A7 | The `list:` keyword lets a test drive a non-empty list through the FUNCTION. It does not reach the TASK: `PENDING` is a frozen constant and no gate root can make the task see a non-empty one, so a mutation deleting the abort left every test green. The fixture prepends one row onto `.pending` through `RUBYOPT`, which is this repository's deliberately-failing-fixture rule applied to the one gate clause with no other route in |
+| P9-31 | `tools/requirement_levels.rb --check` takes an **optional path**, so both exits are proven | `R3`; `NFR-17` | With `TARGET` fixed the gate's own test could only ever assert exit 0, and a `--check` that always exited 0 passed it — measured by mutation. `--check ` compares the same render against a file the test writes to a temp dir, so the failing half needs no fixture tree and nothing under `gems/` is touched |
+| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more (rows 46–52), review round 2 **two** (rows 53–54), review round 3 **seven** (rows 55–61) and review round 4 **two** (rows 62–63), for sixty-two red in all (checklist, "Guards run red"). The two round-2 rows are shapes that SURVIVED round 1 — the brief's own minimum list named both — so the honest reading is that the set run at implementation was short by two, not that two more were found. The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it |
+| P9-33 | The plan's existence-probe list names `:Redactor` at the top level; the filed name is `Instrumentation::Redactor` | plan Task 16 Step 1; design `R8` row 13 | `Dexpace::Redactor` does not exist and never did — design `R8`'s own row 13 says "the filed name, not `Dexpace::Redactor`". The probe list was corrected at execution; every probe answers true, so no `R3` divergence was filed |
+| P9-34 | **Every wait in the four new suites carries a bound**, and ASYNC-3's expired ENTRY wait is `:vacuous` rather than `:failed` | `ASYNC-3`, `SEAM-12`, `XCUT-11`; 8a's `await_closed_connection` | Review round 1. Three waits had none — `Shutdown#check_release`'s `transport.entered.pop` and the two `.each(&:join)` sites — and the first was demonstrated to HANG for ever against an executor that refuses the post (ASYNC-2's saturated queue, or a closed pool): nothing is ever pushed, the poster rescues the refusal, and the `ensure` that frees the gate and closes the pool is never reached. This gem exists to be run by third parties against THEIR executors, and 8a had already fixed the rule for it — `await_closed_connection` always carries a `timeout:`, so an adapter that never releases fails the assertion instead of hanging the run. The entry wait's expiry is `:vacuous` and not `:failed` because with no unit started there is no "blocking task on a worker thread", which is the antecedent `ASYNC-3`'s own sentence opens with; a MUST-level vacuity blocks the report regardless. The two join sites are Failures naming their clause, with the whole thread set sharing one budget so sixteen stuck threads cost one bound and not sixteen |
+| P9-35 | `PackagingCase::DEFAULT_RESOLVE` rescues `::Gem::LoadError` **by name**, beside `::StandardError` | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15`; the suite's absent-antecedent contract | Review round 1, found by writing the test round 0 asked for: every case in `packaging_suite_test.rb` supplied its own `resolve:`, so the lambda a real driver gets had never run. RubyGems raises `Gem::MissingSpecError` for a name it cannot resolve and that descends from `Gem::LoadError < LoadError < ScriptError` — it is **not** a `StandardError`, so the shipped `rescue ::StandardError` let it past, and it escaped `Runner`'s own bare rescue too and aborted the whole run where the contract is one `:vacuous` result naming the absent unit. Measured on 4.0.6. A defect in this phase's own gem, so `R6`'s report-don't-repair rule does not reach it — the same standing `P9-27` has |
+| P9-36 | Four of this document's own predictions were **overturned by observation**, and each is named here as a prediction that was WRONG | `NFR-7`, `NFR-10`, `NFR-17`, `XCUT-14`; plan Task 15 Step 3 | *A prediction is not an observation.* The disposition table above predicted six ⏳ and three of those predictions are simply wrong on the tree this phase was cut from: `NFR-7`'s was pending phase 0 plan Task 3's reviewed `.rubocop.yml` baseline, which landed — `rubocop` exits 0 on `main` at `582e33a`; `NFR-10`'s was pending 8c plan Task 3's per-gem Ruby floor gate edit, which landed — `VERSIONS` carries `ruby floor:dexpace-transport-async_http 3.3` and `gates:versions` is green; and `NFR-17`'s was pending phase 0 plan Task 2's `minitest ~> 5.25` pin, which landed, so the one finding this design raised against `NFR-17` is closed. The fourth correction is not a mark but a NARRATIVE: the whole `gates:bounded_map` story rested on `async_http`'s client cache being uncapped, and 8c shipped the cap its plan promised. `NFR-4`'s ⏳ is overturned as a mark only and narrowly — the row is ✅ because both gates run, while the `v*` baseline `gates:sig_diff` needs still does not exist — and `NFR-13`'s and `NFR-16`'s predictions HELD. All four corrections were MADE from measurement before Task 15's disposition run, so every mark it recorded is an observation; but only the `gates:bounded_map` one was written down AS a correction at the time, and review round 2 is what names the other three. Plan Task 15 Step 3 asks for the naming in as many words, and it is the naming and not the measurement that tells a later reader which of the two a ✅ is — which matters here because phase 10 reads the checklist first |
+| P9-37 | `gates:bounded_map`'s allowlist is **re-adjudicated against as-built paths**, ships at six entries, and the integrity test gains the clause that would have caught the stale one | `XCUT-14`; plan Task 13 | The plan filed four keys. One — `gems/dexpace-core/lib/dexpace/configuration.rb` — silenced nothing, because 5a's two accumulators live in `configuration/builder.rb`; a key that silences nothing passes the plan's own "every key names a live file" test, which is exactly how a stale entry survives an adjudication. The scan reports six sites the gate must not fail on: `bounded_map.rb`, `instrumentation/event.rb` and `conformance/recording_span.rb` as filed, plus `configuration/builder.rb`, `auth/challenges.rb` (one parse's accumulator inside a `private_constant Parser`, reset at every `open_challenge` and dropped with the parse — `event.rb`'s lifetime argument) and `async_http/clients.rb` (`@by_key`, drained inside the insert's own `synchronize`). The rule is NOT narrowed and the keyed-read narrowing stays rejected: six is far short of the dozen open question 2 names as the point where the gate would be telling us the invariant is not held. The integrity test now asserts each key names a live file AND that the scan still reports a Hash ivar there |
+| P9-38 | `InvariantSuite::Models::Borrowed` carries a **second, independent teardown route**, so `XCUT-22`'s "still usable" `Check` can fail | `XCUT-22` | Review round 2. `usable?` was `!@closed`, the exact negation of the `closed?` the FIRST `Check` already asserts, so no subject the suite can build separated the two: replacing the second `Check`'s condition with a literal `true` left the whole gem's suite green, while the code comment and the checklist row both claimed it caught a component that tore the resource down another way. `Borrowed#finish` is that other way — `Net::HTTP` spells its teardown exactly that, a pooled client spells it "retire", and an adapter tearing down a caller's SOCKET rather than the caller's client leaves `closed?` false — and `usable?` now reads both flags. `TearingSeam` drives it. The `ExecutorSuite` twin never had the problem: it posts work to the underlying executor after the holder's close, which is an observation of its own |
+| P9-39 | `ExecutorCase#shutdowns`' event-NAME match is given a double that logs something **besides** its shutdown | `SEAM-25`, `XCUT-13`, `XCUT-22` | Review round 2. The count matches the payload's `Keys::EVENT` entry, but every double in `executor_suite_test.rb` emitted the shutdown payload and nothing else, so nothing separated "count the shutdown events" from "count the sink's writes" — and replacing the match with a bare `entries.count` left the file, the pool driver and the whole `rake test:gems` green, with four assertions reading that count. An executor that logged one unrelated line and emitted no shutdown would have passed `SEAM-25`; a conforming one that logged anything at all would have failed `XCUT-22`. `ChattyPool` emits one other event payload and one bare String message at construction, which nothing in the SPI forbids and which a real adapter's logger carries plenty of. No code changed — the code was right and only the guard was missing |
+| P9-40 | The audit's own driver is **one added file under `gems/dexpace-core/test/`**, and the Module layout's file list above is wrong where it says otherwise | `R6`; `P9-9`; the Module layout | Review round 3. Two sentences in this document cannot both hold: the Module layout says "**no file in `gems/dexpace-core/` at all**", and `P9-9` says "the driver is `dexpace-core`'s own suite and knows which ivar is which". The second is the one that survives, because the seven subjects `InvariantSuite` cannot name itself — the seam, the transport, the bounded map and its store, the cnonce draw, the redirect hop and the credential hop — are core's own objects, and core's test-support doubles are reachable from core's suite and from nowhere else; 8a's precedent already puts a driver in the tree whose objects it drives. The bend is as narrow as it can be: **`gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` is ADDED and nothing else under `gems/dexpace-core/` is modified, renamed or deleted** — not a test, not a support double, not a manifest row, and above all nothing under `lib/` or `sig/`. `R6` exists so that phase 9 does not REPAIR what it audits, and an added test file repairs nothing. `git diff --name-status main.. -- gems/dexpace-core` is one path, status `A`, which is the mechanical statement of it. The Module layout's sentence is left standing rather than quietly rewritten: this row IS the correction, and an edited file list would leave the next reader unable to tell which of the two was decided. The checklist's and the roadmap's boundary sentences name the file too |
+| P9-41 | Seven `XCUT` assertions shipped with **no committed non-conforming double**, and five of them could be neutralised WHOLE with the suite green | `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19`, `XCUT-24`; `R3` | Review round 3, and the same class of defect rounds 1 and 2 found — a guard this phase wrote that could not go red — at its widest. Nothing in the repository drove a subject that made any clause of `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7` or `XCUT-10` fail, so each reported `:passed` because it could not report anything else; `XCUT-19`'s and `XCUT-24`'s clauses survived a literal-`true` replacement for the same reason, their apparent coverage being an artefact of `-w` raising on an orphaned local rather than of any assertion. `invariant_suite_core_test.rb`'s own header named `XCUT-6`, `XCUT-7` and `XCUT-10` among the IDs it drove, and the checklist claimed the (b) evidence universally. **In an audit phase this is `R3`'s own failure one level up**: `R3` stops an assertion passing because its subject was never built, and this is an assertion passing because its own falsifiability was never built. Thirteen defective stand-in cores now drive all seven, in the shape that file already used — a patched `Resilience` or `Instrumentation` whose `const_missing` falls through to the real core, so one test changes one subject. Measured: neutralising each of the seven assertions whole and warning-free (`return nil if subject` as the body's first line, so no local is orphaned and `NFR-6`'s hook cannot mask the result) reddens exactly the new tests for it, thirteen across the seven. No first-party code changed and none should have: the assertions were right and only the negative controls were missing. `XCUT-3` and `XCUT-12` are left without one deliberately, and the checklist names them — a double for either would have to stay in the wait past the assertion's own bound, which leaks the thread the base case's teardown counts |
+| P9-42 | `XCUT-18`'s **model-layer** assertion had no committed non-conforming double either, and the claim that covered it counted rows rather than assertions | `XCUT-18`; `R3`; the checklist's evidence paragraph | Review round 4, and `P9-41` one row narrower. Round 3's sweep neutralised all forty-five assertion bodies in the four suites one at a time and four survived; three were accounted for — `XCUT-3`'s and `XCUT-12`'s, which the checklist names, and a private clause helper whose parent assertion is caught — and the fourth, `HeaderSyntax#header_syntax_validation`, was named nowhere. It could be neutralised whole with the whole of `rake test:gems` green, because both of its committed tests pin the model assertion `:passed` and drive only the call-site one. **The claim could not see it because it was counted in rows**: an ID appears once however many assertions carry it, `XCUT-18` carries two, and the row was covered on one of them — so the evidence paragraph now says ASSERTION and names the sweep as what makes it checkable. Two defective stand-in cores drive it: a permissive validator that refuses nothing, and one applying the NAME rule to outbound values as well — the second because HTAB refused everywhere satisfies every other clause, so the asymmetry that IS the requirement would otherwise rest on the real subject alone. Measured: neutralising the assertion whole reddens both (row 62), deleting the HTAB-accepted `Check` alone reddens the second (row 63). No first-party code changed |
+
---
## Work phase 9 postponed, and who owns it now
diff --git a/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb
new file mode 100644
index 0000000..e16300d
--- /dev/null
+++ b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+# SEAM-12, SEAM-25, XCUT-11, XCUT-13, XCUT-22, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17. The
+# first-party driver for dexpace-conformance's ExecutorSuite (phase 9's plan Task 11), in the
+# adapter gem's own test tree following 8a's placement.
+#
+# Three of the four factories are real SDK objects and none is a stand-in for one:
+#
+# * `build:` is `Pool.build`, wired to the case's own recorder through a real
+# `Instrumentation::Logger`, because SEAM-25's event is the ONLY channel a filed executor
+# exposes a shutdown on -- without `record_events: true` an unlatched pool passes XCUT-13.
+# * `borrow:` is core's `Transport.async_over` bridge, which is Closeable with `owned: false`:
+# the SDK object that takes a caller's executor and must never shut it down (XCUT-22).
+# * `functional:` is that same bridge over an INLINE executor -- a lightweight async transport
+# that manages no lifecycle at all, which is exactly ASYNC-17's subject. The pool is not, and
+# handing the pool there would assert the opposite requirement.
+#
+# ASYNC-3 is WAIVED BY ID and declared `would_fail:`. It is an unsatisfied MUST (design §10.5):
+# a worker blocked in an uninterruptible call cannot be released, because §8.3 bans Thread#raise,
+# Thread#kill and Timeout.timeout outright. The assertion is written to FAIL rather than to be
+# absent, and the waiver prints `waived (would fail): ASYNC-3` -- never passed and never vacuous.
+# `assertion_would_fail` below runs it UNWAIVED and asserts it really does fail, so the waiver
+# cannot outlive the limitation it records.
+require_relative "../../../test_helper"
+require "dexpace/async/thread"
+require "dexpace/conformance"
+
+# The pool's run of the shared executor suite: one generated test per assertion, plus the
+# report-level checks the waiver rests on.
+class DexpaceAsyncThreadConformanceTest < DexpaceTestCase
+ Conformance = Dexpace::Conformance
+ SUITE = Conformance::ExecutorSuite
+ Pool = Dexpace::Async::Thread::Pool
+
+ # Four workers: enough for SEAM-12's sixteen concurrent posts to interleave, small enough that a
+ # leaked pool is obvious in the thread count DexpaceTestCase's teardown takes.
+ SIZE = 4
+ # The one unsatisfied MUST this suite carries, waived by ID in the first-party build.
+ UNSATISFIED = "ASYNC-3"
+
+ # A resource-free executor: it runs the unit on the caller's thread and owns nothing, so the
+ # bridge over it is the lightweight implementation ASYNC-17 says needs no lifecycle management.
+ class PoolInlineExecutor
+ # @yield the unit of work, run inline on the caller's own thread
+ # @return [nil]
+ def post
+ yield
+ nil
+ end
+ end
+
+ # A transport the bridge will accept; nothing in ASYNC-17 reaches it, and it is never dispatched.
+ class PoolIdleTransport
+ # @return [nil] the transport seam's three positionals, so Registry.callable? holds
+ def call(_request, _options, _cancellation) = nil
+ end
+
+ SUITE.assertions.each_with_index do |assertion, index|
+ define_method(format("test_%02d_%s", n: index,
+ name: assertion.name.gsub(/\W+/, "_"),)) do
+ drive(assertion)
+ end
+ end
+
+ test "the suite reports no failure, no error and no unaccepted MUST-level vacuity" do
+ report = SUITE.run(**suite_arguments)
+
+ assert_empty(report.failures.map { |result| described(result) })
+ assert_empty(report.errors.map { |result| described(result) })
+ assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") })
+ assert_predicate(report, :passed?)
+ end
+
+ # The waiver is a claim about the port, so it is measured rather than asserted by description:
+ # ASYNC-3 is waived above because it FAILS, and this runs the same assertion with no waiver and
+ # requires that failure. The day an interruptible wait exists, this goes red and the waiver goes.
+ test "the waived MUST really fails, which is what makes waiving it a record and not a hole" do
+ report = SUITE.run(**case_arguments, waive: [], would_fail: [])
+
+ failed = report.failures.map { |result| result.assertion.ids }.flatten
+
+ assert_includes(failed, UNSATISFIED,
+ "ASYNC-3 no longer fails; the waiver and §10.5's entry are now stale",)
+ end
+
+ # Design R5: the waived MUST is rendered as a waiver that names the failure, never as a pass.
+ test "the report prints the unsatisfied MUST as waived (would fail), never as passed" do
+ rendered = SUITE.run(**suite_arguments).to_s
+
+ assert_includes(rendered, "waived (would fail): #{UNSATISFIED}")
+ end
+
+ private
+
+ def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}"
+
+ def drive(assertion)
+ return skip("waived: #{UNSATISFIED}") if assertion.ids.include?(UNSATISFIED)
+
+ assertion.call(Conformance::ExecutorCase.new(**case_arguments))
+ rescue Conformance::Vacuous => error
+ skip("vacuous: #{error.reason}")
+ rescue Conformance::Failure => error
+ flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \
+ "got #{error.actual.inspect})")
+ end
+
+ def suite_arguments
+ case_arguments.merge(waive: [UNSATISFIED], would_fail: [UNSATISFIED])
+ end
+
+ def case_arguments
+ { build: executor_factory, borrow: borrow_factory, functional: functional_factory,
+ record_events: true, }
+ end
+
+ # `events:` arrives only when the case has a recorder; a real Logger is what turns that duck-typed
+ # sink into the thing Pool#close emits through, so the payload the recorder sees is the one a
+ # host's own logger would see.
+ def executor_factory
+ lambda do |events: nil|
+ logger = events.nil? ? nil : Dexpace::Instrumentation::Logger.build(sink: events)
+ Pool.build(size: SIZE, logger: logger || Dexpace::Instrumentation::Logger::NULL)
+ end
+ end
+
+ # Core's own borrowing holder: Closeable with `owned: false`, so its close latches and releases
+ # nothing. Closing it must leave the pool accepting work, which is XCUT-22 exactly.
+ def borrow_factory
+ ->(pool) { Dexpace::Transport.async_over(PoolIdleTransport.new, executor: pool) }
+ end
+
+ # The same bridge over a resource-free executor: no thread, no queue, no timer, and a close that
+ # is a latch and nothing else.
+ def functional_factory
+ lambda do |events: nil|
+ _ = events
+ Dexpace::Transport.async_over(PoolIdleTransport.new, executor: PoolInlineExecutor.new)
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/APPENDIX_B.md b/gems/dexpace-conformance/APPENDIX_B.md
new file mode 100644
index 0000000..ef5c102
--- /dev/null
+++ b/gems/dexpace-conformance/APPENDIX_B.md
@@ -0,0 +1,116 @@
+# Appendix B coverage map
+
+One row per item of `docs/product-spec/appendix-b-conformance-test-checklist.md` — sixty-one, in
+the specification's own order — naming the requirement IDs that item exercises, the evidence that
+covers it, and a status.
+
+Regenerate the ID column and the row set with `ruby tools/appendix_b.rb`. The generator reads this
+file first and carries every hand-written **Evidence** and **Status** cell over, so a row's
+judgement survives a regeneration and a dropped or added checklist item does not.
+
+## The statuses
+
+| Status | What it means |
+|---|---|
+| `suite` | An assertion in `dexpace-conformance`: this gem's own, portable across implementations of one seam |
+| `by reference` | A test file in the gem that owns the subsystem. **See the limit below.** |
+| `restated per §9.3` | Design §9.3 restates the item for this port, and the restatement is what is checked |
+| `scoped out` | This port declines the item or a clause of it, with the reason in the row |
+
+## What the checks over this map establish, and what they do not
+
+Three checks run in `test/gates/appendix_b_test.rb`:
+
+1. the table has **exactly** as many rows as appendix B has items, and each section's count matches
+ the count parsed from the specification's own text;
+2. every row's **ID column equals** the ID set parsed from that item's own text — 276 distinct IDs
+ over the 61 items, no ID in two items, at most 12 in one — so no row can be wrong about which
+ requirements its item covers, and every ID appendix B names is in some row by construction;
+3. every row's **evidence path exists** on disk.
+
+**What they do not establish: that a referenced test asserts the behaviour the item describes.** A
+`by reference` row's evidence is a test file in a gem this phase does not own, and nothing makes a
+test file announce which appendix-B item it covers. Such a row proves an ID is *claimed* and a file
+*exists* — never more (design `P9-7`). `Aggregate::PREAMBLE` says the same thing about a green
+suite run, so a reader who meets only one of the two documents is still told.
+
+**The closing condition, priced and deferred rather than overlooked.** The gap shrinks as the
+`by reference` rows do, and the event that makes shrinking them worth its cost is the one
+`docs/first-release.md` § Post-release triggers already records for lifting `B.1`, `B.2` and `B.5`:
+**a second implementation** of the pagination engine, the SSE reader or the configuration chain.
+Until that fires, the only other route is a per-section ID reconciliation done by hand once and then
+checked — a piece of work rather than a line, which phase 9 does not take.
+
+## Why `B.1`, `B.2` and `B.5` are `by reference`
+
+§9.3's argument for shipping `dexpace-conformance` as a gem is portability across *implementations
+of one seam*: "the *same* assertions could not run unchanged against `dexpace-transport-async_http`
+or a future `httpx` adapter — which is the whole point." Pagination, SSE and the configuration chain
+have exactly one implementation each, and `PAGE-8` makes the pagination engine stateless and
+shareable rather than pluggable. A lifted assertion over a single subject is not more true for
+having moved gems; it is a test with one subject living in a package whose purpose is many, and it
+costs a second file to keep in step (`P9-1`).
+
+| Section | Item | IDs | Evidence | Status |
+|---|---|---|---|---|
+| B.1 | 1 | PAGE-1, PAGE-2, PAGE-3 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference |
+| B.1 | 2 | PAGE-6, PAGE-7, PAGE-8 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference |
+| B.1 | 3 | PAGE-9, PAGE-10 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference |
+| B.1 | 4 | PAGE-11, PAGE-12, PAGE-14, PAGE-15 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference |
+| B.1 | 5 | PAGE-13 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference |
+| B.1 | 6 | PAGE-16, PAGE-17, PAGE-18, PAGE-20, PAGE-19 | gems/dexpace-core/test/dexpace/page/link_strategy_test.rb | by reference |
+| B.1 | 7 | PAGE-21, PAGE-22, PAGE-23, PAGE-24 | gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb | by reference |
+| B.1 | 8 | PAGE-25, PAGE-26, PAGE-27, PAGE-28, PAGE-29, PAGE-30, PAGE-31, PAGE-32, PAGE-33 | gems/dexpace-core/test/dexpace/page/async_paginator_test.rb | by reference |
+| B.1 | 9 | PAGE-34, PAGE-35 | gems/dexpace-core/test/dexpace/page/fetchers_test.rb | by reference |
+| B.1 | 10 | PAGE-36 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference |
+| B.2 | 1 | SSE-1, SSE-2, SSE-3, SSE-4, SSE-5, SSE-6, SSE-7, SSE-8, SSE-9, SSE-10, SSE-11, SSE-12 | gems/dexpace-core/test/dexpace/sse/reader_test.rb | by reference |
+| B.2 | 2 | SSE-13, SSE-14, SSE-15, SSE-16, SSE-17, SSE-18, SSE-19 | gems/dexpace-core/test/dexpace/sse/boundaries_test.rb | by reference |
+| B.2 | 3 | SSE-20, SSE-21, SSE-22 | gems/dexpace-core/test/dexpace/sse/event_test.rb | by reference |
+| B.2 | 4 | SSE-23, SSE-24, SSE-25, SSE-26, SSE-27, SSE-28, SSE-29, SSE-30, SSE-31, SSE-32 | gems/dexpace-core/test/dexpace/sse/stream_test.rb | by reference |
+| B.2 | 5 | SSE-33, SSE-34, SSE-35, SSE-36 | gems/dexpace-core/test/dexpace/sse/typed_stream_test.rb | by reference |
+| B.2 | 6 | SSE-37, SSE-38, SSE-39, SSE-40, SSE-41 | tools/serde_boundary.rb | suite |
+| B.3 | 1 | SERDE-1, SERDE-2 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb | by reference |
+| B.3 | 2 | SERDE-3, SERDE-4 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite |
+| B.3 | 3 | SERDE-5, SERDE-6, SERDE-7, SERDE-8 | gems/dexpace-core/test/dexpace/serde/witness_test.rb | restated per §9.3 |
+| B.3 | 4 | SERDE-9, SERDE-10, SERDE-11, SERDE-12, SERDE-13 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite |
+| B.3 | 5 | SERDE-14, SERDE-15, SERDE-16, SERDE-17, SERDE-18, SERDE-19, SERDE-20, SERDE-30 | gems/dexpace-core/test/dexpace/serde/tristate_test.rb | by reference |
+| B.3 | 6 | SERDE-21, SERDE-22, SERDE-23, SERDE-24, SERDE-25, SERDE-26, SERDE-29 | gems/dexpace-core/test/dexpace/serde/scalars_test.rb | by reference |
+| B.3 | 7 | SERDE-27, SERDE-28 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb | by reference |
+| B.4 | 1 | OBS-1, OBS-2, OBS-3, OBS-4, OBS-5, OBS-6, OBS-7, OBS-8, OBS-9, OBS-40 | gems/dexpace-core/test/dexpace/instrumentation/event_test.rb | by reference |
+| B.4 | 2 | OBS-10 | gems/dexpace-core/test/dexpace/instrumentation/diagnostics_test.rb | by reference |
+| B.4 | 3 | OBS-11, OBS-12, OBS-13, OBS-14, OBS-15, OBS-16, OBS-17, OBS-18, OBS-19 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite |
+| B.4 | 4 | OBS-20 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite |
+| B.4 | 5 | OBS-21, OBS-22, OBS-23, OBS-24, OBS-25 | gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb | restated per §9.3 |
+| B.4 | 6 | OBS-26, OBS-27, OBS-28, OBS-29, OBS-30 | gems/dexpace-core/test/dexpace/instrumentation/http_tracer_test.rb | by reference |
+| B.4 | 7 | OBS-31, OBS-32, OBS-33 | gems/dexpace-core/test/dexpace/instrumentation/meter_test.rb | by reference |
+| B.4 | 8 | OBS-34, OBS-35, OBS-36, OBS-37, OBS-38, OBS-39 | gems/dexpace-core/test/dexpace/instrumentation/step_test.rb | by reference |
+| B.5 | 1 | CFG-1, CFG-2, CFG-3, CFG-4, CFG-38 | gems/dexpace-core/test/dexpace/configuration_test.rb | restated per §9.3 |
+| B.5 | 2 | CFG-5, CFG-6, CFG-7 | gems/dexpace-core/test/dexpace/configuration_test.rb | by reference |
+| B.5 | 3 | CFG-8, CFG-9, CFG-10, CFG-11, CFG-12, CFG-13, CFG-14, CFG-37 | gems/dexpace-core/test/dexpace/config_test.rb | by reference |
+| B.5 | 4 | CFG-15, CFG-16, CFG-17, CFG-18, CFG-19, CFG-20, CFG-21 | gems/dexpace-core/test/dexpace/clock_test.rb | by reference |
+| B.5 | 5 | CFG-22, CFG-23, CFG-24, CFG-25, CFG-26, CFG-27, CFG-28 | gems/dexpace-core/test/dexpace/proxy_test.rb | by reference |
+| B.5 | 6 | CFG-29, CFG-30, CFG-31, CFG-32, CFG-33, CFG-34, CFG-35, CFG-36 | gems/dexpace-core/test/dexpace/http_date_test.rb | by reference |
+| B.6 | 1 | TRANSPORT-1, TRANSPORT-2 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/outbound.rb | suite |
+| B.6 | 2 | TRANSPORT-3, TRANSPORT-4, TRANSPORT-5, TRANSPORT-6, TRANSPORT-7, TRANSPORT-8, TRANSPORT-9 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/resilience.rb | suite |
+| B.6 | 3 | TRANSPORT-10, TRANSPORT-11, TRANSPORT-12, TRANSPORT-13, TRANSPORT-14 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/header_drops.rb | suite |
+| B.6 | 4 | TRANSPORT-15, TRANSPORT-16, TRANSPORT-17, TRANSPORT-18, TRANSPORT-19 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/lifecycle.rb | suite |
+| B.6 | 5 | TRANSPORT-20, TRANSPORT-21, TRANSPORT-22, TRANSPORT-23, TRANSPORT-24, TRANSPORT-25, TRANSPORT-26, TRANSPORT-27, TRANSPORT-28, TRANSPORT-29, TRANSPORT-30 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/streaming.rb | suite |
+| B.7 | 1 | ASYNC-1, ASYNC-2 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_test.rb | by reference |
+| B.7 | 2 | ASYNC-3, ASYNC-4, ASYNC-5, ASYNC-6, ASYNC-7 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb | suite |
+| B.7 | 3 | ASYNC-8, ASYNC-9, ASYNC-10, ASYNC-11, ASYNC-12 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_diagnostics_test.rb | by reference |
+| B.7 | 4 | ASYNC-13, ASYNC-14 | gems/dexpace-async-thread/test/dexpace/async/thread/bridge_test.rb | scoped out |
+| B.7 | 5 | ASYNC-15, ASYNC-16, ASYNC-17 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb | suite |
+| B.7 | 6 | ASYNC-18, ASYNC-19, ASYNC-20, ASYNC-21, ASYNC-22 | gems/dexpace-transport-async_http/test/dexpace/transport/async_http/adapter_test.rb | by reference |
+| B.8 | 1 | XCUT-1, XCUT-2, XCUT-3 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb | suite |
+| B.8 | 2 | XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb | suite |
+| B.8 | 3 | XCUT-10 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb | suite |
+| B.8 | 4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb | suite |
+| B.8 | 5 | XCUT-14, XCUT-15 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb | suite |
+| B.8 | 6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb | suite |
+| B.9 | 1 | NFR-1, NFR-2 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite |
+| B.9 | 2 | NFR-3, NFR-4 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite |
+| B.9 | 3 | NFR-5, NFR-6, NFR-7, NFR-17 | Rakefile | restated per §9.3 |
+| B.9 | 4 | NFR-8, NFR-9 | docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md | scoped out |
+| B.9 | 5 | NFR-10 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite |
+| B.9 | 6 | NFR-11 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite |
+| B.9 | 7 | NFR-12, NFR-13, NFR-14, NFR-15, NFR-16 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite |
diff --git a/gems/dexpace-conformance/README.md b/gems/dexpace-conformance/README.md
index f1c324d..b55f04d 100644
--- a/gems/dexpace-conformance/README.md
+++ b/gems/dexpace-conformance/README.md
@@ -3,15 +3,23 @@
Part of the [dexpace Ruby SDK](../../README.md): an HTTP-client toolkit, not an HTTP client.
This gem is the conformance suite every adapter -- shipped or third-party -- is proven against.
-**Status: `0.0.0`, unpublished; the transport suite is built.** `lib/` holds phase 8a's
+**Status: `0.0.0`, unpublished; five suites are built.** `lib/` holds phase 8a's
`Dexpace::Conformance`: the assertion protocol phase 0 postponed -- `Failure`, `Vacuous`,
-`Assertion`, `Result` with its five `STATUSES`, and `Report` -- the twenty-eight-assertion
-`TransportSuite` over twenty-two `TRANSPORT` IDs, `HTTP-17`, `HTTP-18`, `XCUT-18` and `PAGE-36`,
+`Assertion`, `Result` with its five `STATUSES`, and `Report` -- the **thirty-four**-assertion
+`TransportSuite` over twenty-eight `TRANSPORT` IDs, `HTTP-17`, `HTTP-18`, `XCUT-18` and `PAGE-36`
+(twenty-eight over twenty-two at 8a; phase 8c's two groups are the rest),
the `TransportCase` an assertion receives and the `BorrowedPair` a borrowing construction
supplies, the plaintext `WireServer` fixture with its `RecordedRequest`s and fifteen named
`Scripts`, the two thin drivers `MinitestDriver` and `RSpecDriver`, and the two observability
-doubles `RecordingSpan` and `Allocations`. Phase 9 adds the remaining suites beside the
-transport one.
+doubles `RecordingSpan` and `Allocations` -- and phase 9's four further suites beside it:
+`InvariantSuite` (28 assertions over all twenty-four `XCUT` IDs), `PackagingSuite` (8 over
+`NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`, read from published gem metadata),
+`CodecSuite` (2 portable seam properties any wire codec must have) and `ExecutorSuite` (7 over an
+async-runtime adapter's lifecycle), over the shared `Runner`, `Check` and `SharedInstance`, with
+`Levels` -- every requirement ID's normative level, generated from the specification's own index --
+making a MUST-level vacuity a report blocker, and `Aggregate` giving a whole run one verdict and one
+preamble stating what it does not prove. The 61-row appendix-B coverage map is `APPENDIX_B.md`,
+shipped beside this file.
## Install
@@ -52,7 +60,8 @@ consumer. `socket` and `tempfile` are stdlib that stays stdlib on every supporte
## Where to read next
- `docs/sdk-documentation/conformance.md` -- the as-built page: what a green run proves and does
- not, the runner and its report, the drivers, the case, the fixture and the doubles.
+ not, the runner and its report, the drivers, the case, the fixture and the doubles, and the four
+ suites phase 9 added with a runnable example of each mechanism.
- `docs/sdk-documentation/transport-net_http.md` -- the reference adapter this suite is run
against first.
- `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install.
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance.rb b/gems/dexpace-conformance/lib/dexpace/conformance.rb
index e493639..73c5f40 100644
--- a/gems/dexpace-conformance/lib/dexpace/conformance.rb
+++ b/gems/dexpace-conformance/lib/dexpace/conformance.rb
@@ -10,7 +10,24 @@
require_relative "conformance/vacuous"
require_relative "conformance/assertion"
require_relative "conformance/result"
+# The requirement-level map a Report reads to tell a MUST-level vacuity from a SHOULD-level one
+# (phase 9, design R3); generated from appendix C by tools/requirement_levels.rb.
+require_relative "conformance/levels"
require_relative "conformance/report"
+# Phase 9's shared instrument: the one assertion primitive, the one status loop and XCUT-11's
+# structural predicate, then the four suites it carries and the one aggregate over them.
+require_relative "conformance/check"
+require_relative "conformance/runner"
+require_relative "conformance/shared_instance"
+require_relative "conformance/invariant_case"
+require_relative "conformance/invariant_suite"
+require_relative "conformance/packaging_case"
+require_relative "conformance/packaging_suite"
+require_relative "conformance/codec_case"
+require_relative "conformance/codec_suite"
+require_relative "conformance/executor_case"
+require_relative "conformance/executor_suite"
+require_relative "conformance/aggregate"
# The TCPServer fixture and its named scripts (design §9.3; `socket` is permitted to this gem
# alone by the require allowlist's scoped denial, P8-14).
require_relative "conformance/scripts"
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb
new file mode 100644
index 0000000..b62d189
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "report"
+
+module Dexpace
+ module Conformance
+ # One report over every suite in a run. This is the caller phase 8a deferred `Report#to_h`
+ # for, and the place `docs/first-release.md`'s standing blocker -- "before release,
+ # `docs/sdk-documentation/` must state what a green run does and does not prove, and the run's
+ # own report preamble must name the same omissions" -- is made mechanical: PREAMBLE prints on
+ # every render rather than sitting in a document nobody re-reads.
+ module Aggregate
+ # §12 distinguishes "not satisfied" from "holds vacuously", and §9.3 requires a waived gap
+ # to stay visible. So this states, on every run, what a green result does NOT prove.
+ PREAMBLE = [
+ "A pass here proves the assertions below ran green. It does not prove:",
+ " - TLS verification or connect-timeout classification: the wire fixture speaks",
+ " plaintext only and there is no portable way to make a local listener accept slowly,",
+ " so both are asserted in dexpace-transport-net_http's own suite and not here (8a's",
+ " P8-9), as is TRANSPORT-8, whose antecedent only an adapter's own suite can name",
+ " - anything covered 'by reference' in APPENDIX_B.md, where the evidence is another",
+ " gem's test file: the row proves the ID is claimed and the file exists, never that",
+ " the behaviour is asserted (phase 9's P9-7)",
+ " - any requirement whose assertion is listed below as waived or vacuous",
+ " - a MUST-level requirement whose assertion is vacuous and that no accepted_vacuous:",
+ " citation names: such a result is listed below as a report blocker, fails this run,",
+ " and earns a docs/first-release.md line (design R3)",
+ ].freeze
+
+ extend self
+
+ # @param reports [Array] one per suite run
+ # @param accepted_vacuous [Hash{String => String}] acceptances that apply to the whole run,
+ # merged over each suite's own
+ # @param would_fail [Array] waived IDs whose assertion would have failed
+ # @return [Report] one report with ONE #passed? over the whole run
+ def run(reports, accepted_vacuous: {}, would_fail: [])
+ Report.merge(reports, preamble: PREAMBLE.join("\n"),
+ accepted_vacuous: accepted_vacuous, would_fail: would_fail,)
+ end
+
+ # @param report [Report]
+ # @return [String] the preamble and the report, in that order
+ def render(report)
+ report.to_s
+ end
+
+ # The generated half of the appendix-B coverage map: every assertion each suite DECLARES,
+ # keyed by requirement ID.
+ #
+ # It takes the SUITES and not a Report, and that is load-bearing: a Report holds only the
+ # assertions that actually RAN, so reading one would let a suite skipped in a given
+ # invocation silently shorten the map -- and the map's own row-count check would then fail
+ # for a reason that has nothing to do with a dropped row (design R7).
+ #
+ # `statuses:` is an optional Report: when given, each row carries the status that run
+ # produced, and an assertion with no result is marked `:not_run` rather than omitted.
+ #
+ # @param suites [Array<#assertions>] every suite whose declarations belong in the map
+ # @param statuses [Report, nil] one run's results
+ # @return [Hash{String => Array}] requirement ID -> its rows
+ def by_requirement_id(suites, statuses: nil)
+ ran = statuses.nil? ? [] : statuses.results #: Array[Result]
+ observed = ran.to_h { |result| [result.assertion.name, result.status] }
+ rows = {} #: Hash[String, Array[untyped]]
+ suites.each_with_object(rows) { |suite, map| add_rows(suite, observed, map) }
+ end
+
+ private
+
+ def add_rows(suite, observed, map)
+ suite.assertions.each do |assertion|
+ row = { suite: suite.name, assertion: assertion.name,
+ status: observed.fetch(assertion.name, :not_run), }
+ assertion.ids.each { |id| (map[id] ||= []) << row }
+ end
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/check.rb b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb
new file mode 100644
index 0000000..40f73cc
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "failure"
+
+module Dexpace
+ module Conformance
+ # The one assertion primitive every phase-9 suite body uses: a condition, the sentence that
+ # says what went wrong, the two values compared, and the requirement IDs the comparison is
+ # evidence for. Its own file because one public constant per file is this repository's rule
+ # and phase 9's own code sits inside the NFR-3 assertion phase 9 ships.
+ module Check
+ extend self
+
+ # @param condition [Object] truthy when the property holds
+ # @param message [String] what went wrong, in words
+ # @param expected [Object] what the assertion expected
+ # @param actual [Object] what it found
+ # @param ids [Array] the requirement IDs this comparison is evidence for
+ # @return [nil]
+ # @raise [Failure] when `condition` is falsey
+ def that(condition, message, expected:, actual:, ids:)
+ return nil if condition
+
+ raise Failure.new(message, expected: expected, actual: actual, requirement_ids: ids)
+ end
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb
new file mode 100644
index 0000000..ff108da
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+module Dexpace
+ module Conformance
+ # What a CodecSuite assertion receives.
+ #
+ # The witness and the source factory are the DRIVER's: phase 2's contract is
+ # `#load(source, witness)` with no witness-less overload, and the source type is the adapter's
+ # (the JSON codec's is a `Dexpace::IO::BufferedSource`), so neither is the suite's to invent.
+ class CodecCase
+ # A sink that counts its own closes, so SERDE-3's "streaming/buffer targets not closed" is a
+ # COUNT and not an inference, and records what was written so an encode can be checked to
+ # have produced something at all.
+ class CountingSink
+ # @return [String] every byte written, in order
+ attr_reader :bytes
+ # @return [Integer] how many times #close was called
+ attr_reader :close_count
+
+ def initialize
+ @bytes = +""
+ @close_count = 0
+ end
+
+ # @param chunk [String] bytes the codec wrote
+ # @return [Integer] the running total written
+ def write(chunk) = (@bytes << chunk).bytesize
+ # @return [Integer] how many closes this sink has now seen -- SERDE-3's count
+ def close = @close_count += 1
+ end
+ private_constant :CountingSink
+
+ # @return [Object] the driver's witness, answering #dexpace_load(parsed, ctx)
+ attr_reader :witness
+
+ # @param build [#call] a zero-argument codec factory
+ # @param witness [Object] a witness the codec's #load accepts
+ # @param source [#call] text -> the source type this codec's #load reads
+ def initialize(build:, witness:, source:)
+ @build = build
+ @witness = witness
+ @source = source
+ end
+
+ # @return [Object] a fresh codec
+ def codec = @build.call
+
+ # @return [CountingSink] a fresh counting sink
+ def sink = CountingSink.new
+
+ # @param text [String] the bytes the source yields
+ def source(text) = @source.call(text)
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb
new file mode 100644
index 0000000..6b732ed
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "codec_case"
+require_relative "runner"
+require_relative "codec_suite/seam"
+
+module Dexpace
+ module Conformance
+ # Appendix B.3's seam half: the two properties a wire codec must have whatever library it
+ # wraps -- SEAM-20 with SERDE-3 (it never closes a target it was handed) and SERDE-9 (no
+ # library exception type escapes the seam).
+ #
+ # **These assertions are RE-DERIVED from the requirement text, not lifted out of
+ # `dexpace-serde-json`'s own suite, and that gem's files are left byte-identical.** A lift
+ # would have moved `assert_closes_nothing`, whose body is the only evidence SEAM-21 has in
+ # that suite, and `assert_failure_model`, which carries SERDE-10 and the ENCODE half of
+ # SERDE-9 as well -- so a lift that took the portable halves would have dropped three
+ # properties on its way out. The relationship here is TransportSuite's to the net_http suite's
+ # instead: a portable assertion beside a gem-local one, both alive.
+ #
+ # The post-v1 `dexpace-serde-oj` is the second subject these two exist for.
+ module CodecSuite
+ extend self
+
+ # What a green run of this suite does NOT prove.
+ PREAMBLE = "dexpace-conformance codec suite: two SEAM properties, re-derived from the " \
+ "requirement text. The offset matrix (SERDE-4), the I/O-error pass-through " \
+ "(SERDE-12), the explicit type token (SEAM-21), the encode half of SERDE-9 and " \
+ "SERDE-10's mapping are asserted in the adapter's own suite and are NOT among " \
+ "the things a green run here proves."
+
+ # @return [Array] the suite's assertions, frozen and ordered
+ def assertions
+ ASSERTIONS
+ end
+
+ # @param build [#call] a zero-argument codec factory
+ # @param witness [Object] a witness the codec's #load accepts
+ # @param source [#call] text -> the source type this codec's #load reads
+ # @param waive [Array] requirement IDs recorded :waived without running
+ # @param around [#call, nil] the suite contract's clause 9 wrapper
+ # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities
+ # @param would_fail [Array] waived IDs whose assertion would have failed
+ # @return [Report]
+ def run(build:, witness:, source:, waive: [], around: nil, accepted_vacuous: {},
+ would_fail: [])
+ Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE,
+ accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do
+ CodecCase.new(build: build, witness: witness, source: source)
+ end
+ end
+
+ # The one group, a private module of its own file, so the assertion bodies are not public
+ # surface.
+ ASSERTIONS = Seam::ASSERTIONS
+ private_constant :ASSERTIONS
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb
new file mode 100644
index 0000000..8ae2c50
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+require_relative "../check"
+require_relative "../failure"
+require_relative "../runner"
+require_relative "../vacuous"
+
+module Dexpace
+ module Conformance
+ module CodecSuite
+ # The two portable seam properties, in a group of their own so the assertion bodies are not
+ # public surface: every other suite's groups are private_constants and this one is too, which
+ # is what keeps each suite's public surface exactly `assertions`, `run` and `PREAMBLE`.
+ # A private_constant of CodecSuite.
+ module Seam
+ extend self
+
+ # The value every encode assertion dumps: small, native, and encodable by any codec.
+ SAMPLE = { "a" => 1 }.freeze
+ # Input no codec can decode, which is SERDE-9's antecedent.
+ MALFORMED = "{not json"
+
+ # SEAM-20 and SERDE-3: a codec MUST NOT close a target it was handed. `#dump_to` and never
+ # `#dump`: phase 2's CONTRACT names `#dump_to` and keeps the `#dump` shorthand out of it
+ # deliberately, so a conforming codec need not define `#dump` at all. The bytes are checked
+ # too, so a codec that wrote nothing cannot pass by having closed nothing.
+ def never_closes_its_target(subject)
+ sink = subject.sink
+ subject.codec.dump_to(SAMPLE, sink)
+
+ Check.that(sink.close_count.zero?, "the codec closed a caller-supplied sink",
+ expected: 0, actual: sink.close_count, ids: %w[SEAM-20 SERDE-3],)
+ Check.that(!sink.bytes.empty?,
+ "the codec wrote nothing, so closing nothing proves nothing",
+ expected: "some bytes", actual: sink.bytes.bytesize,
+ ids: %w[SEAM-20 SERDE-3],)
+ end
+
+ # SERDE-9: "failures surface the stable serde type CHAINING THE ORIGINAL CAUSE, no library
+ # type escapes." Three clauses and all three are asserted -- a Dexpace error OUTSIDE the
+ # serde hierarchy, or a serde error with NO cause chained, satisfies neither the "stable
+ # serde type" nor the "chaining the original cause" half.
+ def no_library_type_escapes(subject)
+ raised = capture_failure(subject)
+
+ Check.that(!raised.nil?, "malformed input decoded without raising",
+ expected: "a Dexpace::Serde::Error", actual: "no error", ids: ["SERDE-9"],)
+ Check.that(serde_error?(raised),
+ "a failure outside the SDK's serde hierarchy escaped the seam",
+ expected: "Dexpace::Serde::Error", actual: raised.class.name,
+ ids: ["SERDE-9"],)
+ # The chain is read through core's ONE cause walk rather than through a bare
+ # `raised.cause`: XCUT-9 makes `Dexpace.each_cause` the single walk, `gates:cause_walk`
+ # keeps it single by scanning for any other `#cause` send, and that scan cannot tell a
+ # one-step read from a walk. Using the walk costs nothing and keeps the gate's allowlist
+ # at one entry.
+ Check.that(::Dexpace.each_cause(raised).count > 1,
+ "the serde failure chained no original cause",
+ expected: "a chained #cause", actual: 1, ids: ["SERDE-9"],)
+ end
+
+ # Vacuous and Failure are re-RAISED rather than captured: both are ::StandardError
+ # descendants (8a), so a bare rescue would turn this suite's own vacuity into a PASS.
+ def capture_failure(subject)
+ subject.codec.load(subject.source(MALFORMED), subject.witness)
+ nil
+ rescue Vacuous, Failure
+ raise
+ rescue ::StandardError => error
+ error
+ end
+
+ def serde_error?(error)
+ defined?(::Dexpace::Serde::Error) && error.is_a?(::Dexpace::Serde::Error)
+ end
+
+ ROWS = [
+ [%w[SEAM-20 SERDE-3], "a codec never closes a target it was handed",
+ :never_closes_its_target,],
+ ["SERDE-9", "a decode failure surfaces the SDK's serde type", :no_library_type_escapes],
+ ].freeze
+ private_constant :ROWS
+
+ # @return [Array] this group's assertions, frozen
+ ASSERTIONS = Runner.registry(self, ROWS)
+ end
+ private_constant :Seam
+ end
+ end
+end
diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb
new file mode 100644
index 0000000..9b2d45a
--- /dev/null
+++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+# SPDX-License-Identifier: MIT
+
+# Keys::EVENT and Events::INSTRUMENTATION_SHUTDOWN both live in this one file.
+require "dexpace/instrumentation/keys"
+require_relative "vacuous"
+
+module Dexpace
+ module Conformance
+ # What an ExecutorSuite assertion receives. The executor is a FACTORY, never a constant --
+ # dexpace-conformance declares dexpace-core and nothing else, so it can name no adapter.
+ #
+ # The event recorder is built ONCE PER CASE and passed into the factory as an `events:`
+ # setting, never shared across a run: Runner builds a fresh case per assertion, so an earlier
+ # assertion's shutdown event cannot be counted by a later one. That is testing/4ef070df
+ # applied to a fixture the SUITE constructs rather than one a test writes, and it is the
+ # defect this phase's own planning run hit -- one shared recorder made the CONFORMING double
+ # fail, because three earlier assertions had each built and closed a pool.
+ class ExecutorCase
+ # XCUT-11's own conformance shape: "invoke one shared instance from MANY threads".
+ THREADS = 16
+
+ # The recorder is a SINK, because a sink is where a shutdown is observable. No filed
+ # executor in this repository exposes a shutdown counter: the pool reports its shutdown
+ # exactly once, from its release, through the injected logger -- and `Event#emit` ends in
+ # `@sink.public_send(severity.sink_method) { rendered_record }` with the event tag under
+ # `Keys::EVENT`. So the portable observation is the payload this sink receives, and the
+ # adapter's `build:` lambda is what wires it into its own logger.
+ #
+ # Shaped on core's `_Sink` duck type; every predicate answers true, so an INFO-severity
+ # shutdown is never filtered by `Logger#enabled?` before it reaches here.
+ class EventRecorder
+ def initialize
+ @payloads = []
+ @mutex = ::Thread::Mutex.new
+ end
+
+ # @return [Array