gate: full-language migration proof — migrate the Ruby plugin to a package (#80 step 13) #84

Closed
opened 2026-08-31 23:26:22 +02:00 by buildagent · 5 comments
Member

Split out of #80's "Full-language migration proof" / release-gate step 13, by an explicit deferral. Full record with the measurements: _prdoc/records/80-S43-migration-candidate.md on epic-75-runtime-plugins. Ledger row E9.

#80 requires, as the anti-vacuity gate for the whole epic:

Migrate one complete existing compiled plugin into an external package path … Compare canonical extraction facts and resolved projection. Any intentional delta is separately reviewed and pinned; aggregate-count similarity is not equivalence. This is the anti-vacuity gate for "real plugin architecture." XAML alone proves markup extensibility, not general language extensibility.

Why this is its own issue and not a step on the epic branch

Not because the ABI is inexpressive. The measurement came out the other way: for Ruby, today's fact ABI can express every fact the builtin plugin produces — zero registry additions, zero ABI record changes, zero schema migration. Ruby's 7 symbol kinds are all in SYMBOL_KINDS, its 6 ref kinds are all in REF_KINDS, its role bits are inside GRANTABLE_ROLES, and its binding-ref qualifier is a contiguous verbatim span that receiver_span can carry.

What blocks it is tooling plus one product decision:

blocker evidence
There is no guest SDK. _prdoc/guides/80-extractor-abi.md §5 says so itself: no code-index-sdk crate, no guest-side helper library, no published example package. The only guests anywhere in the repo are hand-written .wat fixtures. guide §5
A guest cannot map a kind NAME to a kind ID. The tree stream carries u16 kind and field ids, and the guest has zero imports — no ts_language_symbol_for_name, no string table. The XAML extractor sidesteps this by keying on source text, which works for markup and cannot work for a language: nothing in a function_item's bytes distinguishes it from a call_expression's. crates/plugin-host/src/tree.rs
No Rust→wasm guest build has ever been attempted here. The zero-imports property is proven only against .wat. crates/plugin-host/tests/extractor_engine.rs
A package may not claim a file a builtin claims, and there is no override. The refusal is against a compile-time static BUILTIN_CLAIMS; languages.enabled is, in activation.rs's own words, "read by NOTHING in this workspace outside its own tests". crates/package/src/builtin.rs, crates/indexer/src/packages.rs

Squeezed into one branch step this produces either a .wat toy — the exact vacuity #80 names — or a harness whose two legs collapse into one.

The candidate: Ruby (measured)

language plugin LOC emits attr_start_line? emits is_extension? grammars files.lang ids
ruby 1859 no (0/5 sites) no (0/5 sites) 1 1
php 1984 no (0) no (0) 1 1
python 2049 yes (3) no 1 1
rust 3005 yes (8) no 1 1
typescript 3217 yes (7) no 3 2
csharp 3336 yes (6) yes (8) 1 1

Ruby is the smallest plugin, has the smallest node-kind vocabulary (~32 kinds vs ~98–103 for C#/TS), one grammar, one language id — and is one of only two languages emitting neither field the fact ABI cannot carry. A migration whose first assertion must be "these two columns are pinned deltas" is a weaker gate than one where they are identical by construction.

Also: tree-sitter-ruby's scanner includes only <string.h> and <wctype.h>, both already in tests/grammars/shim/, so the existing XAML grammar recipe should apply with no new shim work.

Runner-up PHP matches on those two holes but carries ~75% more node kinds, and both refactor.rs and qualified_name_separator are keyed on lang == "php". TypeScript is disqualified structurally: Manifest.grammar is a single non-Option field, so one package cannot ship the three grammars its plugin selects between.

Plan

Phase 1 — feasibility, allowed to fail

  1. Build tree-sitter-ruby.wasm via the existing reproducible recipe. Two independent build dirs must produce byte-identical output; the module must parse a fixture to a node count matching the native grammar.
  2. Prove the wasm grammar and the native crate agree on every (kind name, named) → id and field id — not just the ~43 Ruby uses — with a different grammar version as a negative control. If these disagree, every emitted fact is silently wrong, so this is the cheap early kill.
  3. Rust guest spike — the step that can end the issue. Does a wasm32-unknown-unknown cdylib satisfy zero-imports, export the required symbols, precompile, and land under MAX_EXTRACTOR_BYTES (8 MiB) as a .cwasm? Do this before anything in Phase 2.

Phase 2 — the SDK (minimum a Ruby guest needs)
4. A guest cursor over the 16-byte pre-order stream. Non-vacuity: for every fixture, the cursor's traversal must produce an identical (kind_id, field_id, start, end) sequence to tree_sitter::TreeCursor — every node, not a spot check.
5. A guest-side fact encoder, round-tripped against code_index_abi::validate for every legal record shape.

Phase 3 — the package
6. Port the extraction logic, with the host-side bounds (MAX_TRANSPARENT_STEPS, MAX_PARSE_DEPTH — there are two shipped quadratics behind those). This step does not get to declare itself done; its gate is axis B below, against expectations it may not edit.
7. Generate the expectations from the builtin plugin, written by a different agent than step 6. Every fact table present (absent = ungraded), at least one [[absent]] decoy per fixture.
8. Pack, approve and activate through the shipped binaries, xaml_package_e2e.rs as template.

Phase 4 — parity, on three axes

  • A — routing: compare ClaimTables directly, no indexing.
  • B — canonical facts: reuse crates/package/src/expect.rs, which already grades a present table exhaustively and already supports [[absent]] decoys. Three-way: builtin-live == checked-in expectation == package worker output, so the checked-in file is a builtin-drift tripwire and not a bless-to-green baseline.
  • C — resolved projection: reuse crates/indexer/tests/corpus/mod.rs's project(). Its REF_SQL already renders targets as path#name@line rather than rowids, and its own doc already says comparing counts "hides real ones" — which is #80's "aggregate-count similarity is not equivalence", already implemented. Do not write a second comparator.

Exactly two normalisations are permitted (shadow-path suffix; files.lang), each asserted applied to every row. Anything else is a delta with a named mechanism in a record file, or the gate is red.

Non-vacuity the harness must carry:

  • Producer identity read from the database, not from an env var: package-leg rows must join to extraction_components with the package key, builtin-leg rows with builtin. This is the direct answer to a parity test whose two legs collapsed under an env knob — the legs are distinguished by a row written by the thing under test.
  • Absolute floors from the builtin leg asserted on both, so empty-vs-empty cannot pass.
  • All six pool capabilities asserted granted, so an inert package cannot compare "no resolutions" against "no resolutions".
  • Mutations that must be run and must go red, including: delete one guest arm; shift a name_span by one byte; swap two entries in the generated id table; remove the package from the approval record (must fail on identity, not by comparing empty); delete the shadow-suffix normalisation.

Cost: the corpus leg is corpus-gated/nightly with its own baseline file — it must not write tests/corpus/baseline.json or the cost baseline, so a package-leg movement cannot launder a builtin one. Two ceilings, because SQLite work counters cannot see a slow wasm guest: the usual counters plus extraction wall clock expressed as a ratio to the builtin leg measured on the same box, never an absolute.

Schema: no migration. Every column axis C reads already exists.

Out of scope (own issues)

  • Making the builtin claim set project-scoped so a package can own .rb outright — a product feature; shadow extensions get this gate green without it.
  • Closing the attr_start_line wire gap (blocks rust/python/typescript/csharp; an optional-tag addition, no migration) — scope it with whichever language migrates second.
  • Closing is_extension (blocks C#; a resolver change, not an ABI change).
  • Migrating a second language. #80 says "one complete existing compiled plugin".
  • A published, versioned author-facing SDK — 80-abi-support-policy.md already names it as an unmet condition of a stable ABI.

Blocks #80 / #75. Adjacent deferral: release-gate step 12 (cross-platform) — ledger E10, needs runners this project does not have.

Split out of #80's "Full-language migration proof" / release-gate **step 13**, by an explicit deferral. Full record with the measurements: `_prdoc/records/80-S43-migration-candidate.md` on `epic-75-runtime-plugins`. Ledger row **E9**. #80 requires, as the anti-vacuity gate for the whole epic: > Migrate **one complete existing compiled plugin** into an external package path … Compare canonical extraction facts and resolved projection. Any intentional delta is separately reviewed and pinned; **aggregate-count similarity is not equivalence**. This is the anti-vacuity gate for "real plugin architecture." XAML alone proves markup extensibility, not general language extensibility. ## Why this is its own issue and not a step on the epic branch Not because the ABI is inexpressive. **The measurement came out the other way:** for Ruby, today's fact ABI can express every fact the builtin plugin produces — zero registry additions, zero ABI record changes, zero schema migration. Ruby's 7 symbol kinds are all in `SYMBOL_KINDS`, its 6 ref kinds are all in `REF_KINDS`, its role bits are inside `GRANTABLE_ROLES`, and its `binding`-ref qualifier is a contiguous verbatim span that `receiver_span` can carry. What blocks it is **tooling plus one product decision**: | blocker | evidence | |---|---| | **There is no guest SDK.** `_prdoc/guides/80-extractor-abi.md` §5 says so itself: no `code-index-sdk` crate, no guest-side helper library, no published example package. The only guests anywhere in the repo are hand-written `.wat` fixtures. | guide §5 | | **A guest cannot map a kind NAME to a kind ID.** The tree stream carries `u16` kind and field ids, and the guest has **zero imports** — no `ts_language_symbol_for_name`, no string table. The XAML extractor sidesteps this by keying on **source text**, which works for markup and cannot work for a language: nothing in a `function_item`'s bytes distinguishes it from a `call_expression`'s. | `crates/plugin-host/src/tree.rs` | | **No Rust→wasm guest build has ever been attempted here.** The zero-imports property is proven only against `.wat`. | `crates/plugin-host/tests/extractor_engine.rs` | | **A package may not claim a file a builtin claims, and there is no override.** The refusal is against a compile-time `static BUILTIN_CLAIMS`; `languages.enabled` is, in `activation.rs`'s own words, "read by NOTHING in this workspace outside its own tests". | `crates/package/src/builtin.rs`, `crates/indexer/src/packages.rs` | Squeezed into one branch step this produces either a `.wat` toy — the exact vacuity #80 names — or a harness whose two legs collapse into one. ## The candidate: Ruby (measured) | language | plugin LOC | emits `attr_start_line`? | emits `is_extension`? | grammars | `files.lang` ids | |---|---|---|---|---|---| | **ruby** | **1859** | **no (0/5 sites)** | **no (0/5 sites)** | **1** | **1** | | php | 1984 | no (0) | no (0) | 1 | 1 | | python | 2049 | yes (3) | no | 1 | 1 | | rust | 3005 | yes (8) | no | 1 | 1 | | typescript | 3217 | yes (7) | no | 3 | 2 | | csharp | 3336 | yes (6) | **yes (8)** | 1 | 1 | Ruby is the smallest plugin, has the smallest node-kind vocabulary (~32 kinds vs ~98–103 for C#/TS), one grammar, one language id — and is one of only two languages emitting **neither** field the fact ABI cannot carry. A migration whose first assertion must be "these two columns are pinned deltas" is a weaker gate than one where they are identical by construction. Also: `tree-sitter-ruby`'s scanner includes only `<string.h>` and `<wctype.h>`, both already in `tests/grammars/shim/`, so the existing XAML grammar recipe should apply with no new shim work. **Runner-up PHP** matches on those two holes but carries ~75% more node kinds, and both `refactor.rs` and `qualified_name_separator` are keyed on `lang == "php"`. **TypeScript is disqualified structurally**: `Manifest.grammar` is a single non-`Option` field, so one package cannot ship the three grammars its plugin selects between. ## Plan **Phase 1 — feasibility, allowed to fail** 1. Build `tree-sitter-ruby.wasm` via the existing reproducible recipe. Two independent build dirs must produce byte-identical output; the module must parse a fixture to a node count matching the native grammar. 2. Prove the wasm grammar and the native crate agree on **every** `(kind name, named) → id` and field id — not just the ~43 Ruby uses — with a different grammar version as a negative control. **If these disagree, every emitted fact is silently wrong**, so this is the cheap early kill. 3. **Rust guest spike — the step that can end the issue.** Does a `wasm32-unknown-unknown` cdylib satisfy zero-imports, export the required symbols, precompile, and land under `MAX_EXTRACTOR_BYTES` (8 MiB) as a `.cwasm`? Do this before anything in Phase 2. **Phase 2 — the SDK (minimum a Ruby guest needs)** 4. A guest cursor over the 16-byte pre-order stream. Non-vacuity: for every fixture, the cursor's traversal must produce an **identical** `(kind_id, field_id, start, end)` sequence to `tree_sitter::TreeCursor` — every node, not a spot check. 5. A guest-side fact encoder, round-tripped against `code_index_abi::validate` for every legal record shape. **Phase 3 — the package** 6. Port the extraction logic, *with* the host-side bounds (`MAX_TRANSPARENT_STEPS`, `MAX_PARSE_DEPTH` — there are two shipped quadratics behind those). This step does not get to declare itself done; its gate is axis B below, against expectations it may not edit. 7. Generate the expectations from the **builtin** plugin, written by a different agent than step 6. Every fact table present (absent = ungraded), at least one `[[absent]]` decoy per fixture. 8. Pack, approve and activate through the **shipped binaries**, `xaml_package_e2e.rs` as template. **Phase 4 — parity, on three axes** - **A — routing:** compare `ClaimTable`s directly, no indexing. - **B — canonical facts:** reuse `crates/package/src/expect.rs`, which already grades a present table **exhaustively** and already supports `[[absent]]` decoys. Three-way: builtin-live == checked-in expectation == package worker output, so the checked-in file is a builtin-drift tripwire and not a bless-to-green baseline. - **C — resolved projection:** reuse `crates/indexer/tests/corpus/mod.rs`'s `project()`. Its `REF_SQL` already renders targets as `path#name@line` rather than rowids, and its own doc already says comparing counts "hides real ones" — which is #80's "aggregate-count similarity is not equivalence", already implemented. Do not write a second comparator. Exactly **two** normalisations are permitted (shadow-path suffix; `files.lang`), each asserted applied to every row. Anything else is a delta with a named mechanism in a record file, or the gate is red. **Non-vacuity the harness must carry:** - **Producer identity read from the database**, not from an env var: package-leg rows must join to `extraction_components` with the package key, builtin-leg rows with `builtin`. This is the direct answer to a parity test whose two legs collapsed under an env knob — the legs are distinguished by a row *written by the thing under test*. - Absolute floors from the builtin leg asserted on both, so empty-vs-empty cannot pass. - All six pool capabilities asserted granted, so an inert package cannot compare "no resolutions" against "no resolutions". - Mutations that must be run and must go red, including: delete one guest arm; shift a `name_span` by one byte; swap two entries in the generated id table; remove the package from the approval record (must fail on *identity*, not by comparing empty); delete the shadow-suffix normalisation. **Cost:** the corpus leg is corpus-gated/nightly with **its own** baseline file — it must not write `tests/corpus/baseline.json` or the cost baseline, so a package-leg movement cannot launder a builtin one. Two ceilings, because SQLite work counters cannot see a slow wasm guest: the usual counters **plus** extraction wall clock expressed as a **ratio to the builtin leg measured on the same box**, never an absolute. **Schema: no migration.** Every column axis C reads already exists. ## Out of scope (own issues) - Making the builtin claim set project-scoped so a package can own `.rb` outright — a product feature; shadow extensions get this gate green without it. - Closing the `attr_start_line` wire gap (blocks rust/python/typescript/csharp; an optional-tag addition, no migration) — scope it with whichever language migrates second. - Closing `is_extension` (blocks C#; a resolver change, not an ABI change). - Migrating a second language. #80 says "one complete existing compiled plugin". - A published, versioned author-facing SDK — `80-abi-support-policy.md` already names it as an unmet condition of a *stable* ABI. ## Related Blocks #80 / #75. Adjacent deferral: release-gate **step 12** (cross-platform) — ledger **E10**, needs runners this project does not have.
Author
Member

Correction, same day: Ruby is still the right candidate, but it is not free

The claim in the issue body — "for Ruby, today's fact ABI can express every fact the builtin plugin produces" — was measured on the wrong axis and is false as written. Correcting it here rather than editing it away, because the way it was wrong is itself useful to whoever picks this up.

The gap analysis checked that Ruby's symbol kinds, ref kinds and role bits are all present in the ABI registries. They are. It did not check the validator's strongest rule, name == src[name_span].

What breaks

crates/plugins/src/ruby.rs's Rails association DSL. has_many :posts emits a type ref named Post — singularised and camelised — at the span of the literal :posts. So name != src[name_span], and validate refuses it with fact.span_name_mismatch.

Because validation is all-or-nothing per file, the cost is 11 refs across two whole files (app/models/user.rb, app/models/post.rb), not the 3 offending rows.

Structurally this is a derived fact — the same shape as a binding row — and the ABI carves out exactly one derived kind, by name.

Consequence for this issue

Phase 3 (port the extraction logic) must additionally choose one of:

  1. Add an ABI marker for derived refs, generalising the carve-out that already exists for binding — the preferred option, since it is one mechanism rather than a per-language exception, and it closes the class rather than this instance; or
  2. Accept a pinned recall delta on Rails associations, recorded in the delta file with its measured row count.

Either way it is now a known, bounded, reviewable delta rather than a surprise discovered mid-port — which is what the gap analysis was for. But note how it was actually found: by a test, not by the analysis, and the analysis had already been filed as this issue when the test found it.

RawRef.qualifier's own doc says the qualifier is "exactly as written in source". Measured across all seven languages, that is false in three, by three independent mechanisms:

  • C# — emit_implicit_member_access attaches qualifier = "this" to a bare member name; csharp/Sample.cs contains the string this zero times.
  • Ruby — the @ivar arm attaches qualifier = "self"; ruby/sample.rb contains self zero times.
  • Rust — join_use assembles nested brace-import prefixes: use std::sync::{atomic::{AtomicBool, Ordering}, Arc}; yields std::sync::atomic, which appears nowhere contiguously in the file.

qualifier_span/receiver_span cannot represent any of these. The ABI needs a text field, or those three rules need real source positions. This is a prerequisite for migrating C# or Rust, and a smaller one for Ruby.

Why this was invisible

crates/plugins/tests/abi_projection.rs's seven-language test never projected refs at all — ref coverage was Rust-only, and Rust has no derived-name ref. The qualifier test likewise walked tests/fixtures/rust/project/src alone, two directories away from rust/completeness/, so it reported zero for four releases.

Both are now measured per language and pinned as sets. Ledger rows D28g, D29g, D30g; record _prdoc/records/80-S43-migration-candidate.md corrected in place.

## Correction, same day: Ruby is still the right candidate, but it is **not free** The claim in the issue body — "for Ruby, today's fact ABI can express every fact the builtin plugin produces" — **was measured on the wrong axis and is false as written.** Correcting it here rather than editing it away, because the way it was wrong is itself useful to whoever picks this up. The gap analysis checked that Ruby's symbol kinds, ref kinds and role bits are all present in the ABI registries. They are. It did **not** check the validator's strongest rule, `name == src[name_span]`. ### What breaks `crates/plugins/src/ruby.rs`'s Rails association DSL. `has_many :posts` emits a `type` ref **named `Post`** — singularised and camelised — at the span of the literal `:posts`. So `name != src[name_span]`, and `validate` refuses it with `fact.span_name_mismatch`. Because validation is **all-or-nothing per file**, the cost is **11 refs across two whole files** (`app/models/user.rb`, `app/models/post.rb`), not the 3 offending rows. Structurally this is a derived fact — the same shape as a `binding` row — and the ABI carves out exactly **one** derived kind, by name. ### Consequence for this issue Phase 3 (port the extraction logic) must additionally choose one of: 1. **Add an ABI marker for derived refs**, generalising the carve-out that already exists for `binding` — the preferred option, since it is one mechanism rather than a per-language exception, and it closes the class rather than this instance; or 2. **Accept a pinned recall delta** on Rails associations, recorded in the delta file with its measured row count. Either way it is now a *known, bounded, reviewable* delta rather than a surprise discovered mid-port — which is what the gap analysis was for. But note how it was actually found: **by a test, not by the analysis**, and the analysis had already been filed as this issue when the test found it. ### Related finding, same measurement `RawRef.qualifier`'s own doc says the qualifier is "exactly as written in source". Measured across all seven languages, that is **false in three**, by three independent mechanisms: - **C#** — `emit_implicit_member_access` attaches `qualifier = "this"` to a bare member name; `csharp/Sample.cs` contains the string `this` **zero** times. - **Ruby** — the `@ivar` arm attaches `qualifier = "self"`; `ruby/sample.rb` contains `self` **zero** times. - **Rust** — `join_use` *assembles* nested brace-import prefixes: `use std::sync::{atomic::{AtomicBool, Ordering}, Arc};` yields `std::sync::atomic`, which appears nowhere contiguously in the file. `qualifier_span`/`receiver_span` cannot represent any of these. The ABI needs a text field, or those three rules need real source positions. This is a **prerequisite for migrating C# or Rust**, and a smaller one for Ruby. ### Why this was invisible `crates/plugins/tests/abi_projection.rs`'s seven-language test **never projected refs at all** — ref coverage was Rust-only, and Rust has no derived-name ref. The qualifier test likewise walked `tests/fixtures/rust/project/src` alone, two directories away from `rust/completeness/`, so it reported zero for four releases. Both are now measured per language and pinned as sets. Ledger rows **D28g**, **D29g**, **D30g**; record `_prdoc/records/80-S43-migration-candidate.md` corrected in place.
dhoyer referenced this issue from a commit 2026-09-01 11:36:16 +02:00
Author
Member

Phase 1 step 3 — the Rust guest spike, RUN. It does not end the issue.

This was the step listed as "the step that can end the issue", to be done before anything in Phase 2. It has been executed against the real host contract. The approach survives, and one of this issue's four blockers is already gone.

Blocker status, re-measured

blocker as filed state now
A guest cannot map a kind NAME to a kind ID CLOSED by v0.26.0 — crates/plugin-host/src/kinds.rs, GUEST_KIND_TABLE_DIGEST (#87 part 2)
No Rust→wasm guest build has ever been attempted DONE — this spike
There is no guest SDK stands (Phase 2)
A package may not claim a builtin's file not a blocker here — this issue already scopes it out; shadow extensions get the gate green

What was built and measured

A wasm32-unknown-unknown cdylib on stable 1.98 (the project's MSRV), no_std, panic=abort, opt-level="z", LTO, stripped — implementing the contract in engine.rs: extract(i32,i32,i32,i32) -> i64, memory, src_offset, kind_table_digest.

contract requirement result
zero imports 0 — automatic, no flags, no no_std gymnastics beyond a panic handler
memory exported yes, automatic
extract with the four-i32/i64 signature yes
size vs MAX_EXTRACTOR_BYTES (8 MiB) 171 bytes — 0.002% of budget
precompiles to .cwasm via --precompile yes, 17 760 bytes, rc=0

The zero-import result is the one that mattered most: it is the property that makes fork, open and connect inexpressible, and it holds for an ordinary Rust cdylib without any special handling.

The real obstacle, measured rather than inferred

Stable Rust cannot emit a wasm GLOBAL. #[no_mangle] pub static does not produce a global holding a value — wasm-ld exports the static's address:

declared:  src_offset = 1024          kind_table_digest = 0x0123456789abcdef  (i64)
emitted:   src_offset = 1048584 (i32) kind_table_digest = 1048576            (i32)

Both are addresses, and the digest comes out as i32 where the ABI says i64. That is exactly the state guest_claiming_kind_table_as_i32 exists to pin, so the host refuses a naive Rust guest with Reason::GrammarKindTableMismatch. It fails closed, which is the right direction — but it fails.

The obvious escape is blocked too:

core::arch::global_asm!(".globaltype kind_table_digest, i64, immutable", ...)
error[E0658]: inline assembly is not stable yet on this architecture

Nightly would unblock it, but this project pins stable 1.98 and gates on cargo check (MSRV 1.98), so requiring nightly of package authors is a real cost, not a detail.

The way through, demonstrated rather than proposed

A post-link step that appends the two globals and repoints the exports. Written and run against the actual rustc output:

globals 3 -> 5
  global[3] i32 const = 1024                 <- src_offset
  global[4] i64 const = 81985529216486895    <- kind_table_digest (0x123456789abcdef)
EXPORTS: memory(memory) extract(func) src_offset(global 3) kind_table_digest(global 4)
IMPORTS: 0

171 → 190 bytes, zero imports preserved, and the patched module still precompiles (17 784-byte .cwasm). Nineteen bytes of section rewriting.

This is routine for wasm toolchains — wasm-bindgen and wasm-opt both post-process — and it belongs in the SDK's build step rather than in each package author's hands.

What this means for the plan

Phase 1 step 3 passes. Nothing here invalidates Phases 2–4, and the size headroom means a real Ruby guest has room to be far larger than a spike.

Phase 2 (the SDK) gains one requirement not previously written down: the SDK must own a post-link step that injects src_offset and kind_table_digest as correctly-typed wasm globals, because the guest cannot declare them itself on stable Rust. It should also assert the result — a guest that ships an i32 digest is refused by the host, so the SDK catching it at build time is the difference between a build error and a runtime refusal.

Phase 1 steps 1 and 2 (reproducible tree-sitter-ruby.wasm, and full (kind name, named) → id agreement between the wasm grammar and the native crate) are still unrun and remain the cheap early kill — "if these disagree, every emitted fact is silently wrong".

Artefacts are scratch and not committed; the spike is reproducible from this comment.

## Phase 1 step 3 — the Rust guest spike, RUN. It does not end the issue. This was the step listed as *"the step that can end the issue"*, to be done before anything in Phase 2. It has been executed against the real host contract. **The approach survives**, and one of this issue's four blockers is already gone. ### Blocker status, re-measured | blocker as filed | state now | |---|---| | A guest cannot map a kind NAME to a kind ID | **CLOSED by v0.26.0** — `crates/plugin-host/src/kinds.rs`, `GUEST_KIND_TABLE_DIGEST` (#87 part 2) | | No Rust→wasm guest build has ever been attempted | **DONE — this spike** | | There is no guest SDK | stands (Phase 2) | | A package may not claim a builtin's file | not a blocker here — this issue already scopes it out; shadow extensions get the gate green | ### What was built and measured A `wasm32-unknown-unknown` cdylib on **stable 1.98** (the project's MSRV), `no_std`, `panic=abort`, `opt-level="z"`, LTO, stripped — implementing the contract in `engine.rs`: `extract(i32,i32,i32,i32) -> i64`, `memory`, `src_offset`, `kind_table_digest`. | contract requirement | result | |---|---| | **zero imports** | **0** — automatic, no flags, no `no_std` gymnastics beyond a panic handler | | `memory` exported | yes, automatic | | `extract` with the four-i32/i64 signature | yes | | size vs `MAX_EXTRACTOR_BYTES` (8 MiB) | **171 bytes** — 0.002% of budget | | precompiles to `.cwasm` via `--precompile` | **yes**, 17 760 bytes, rc=0 | The zero-import result is the one that mattered most: it is the property that makes `fork`, `open` and `connect` inexpressible, and it holds for an ordinary Rust cdylib without any special handling. ### The real obstacle, measured rather than inferred **Stable Rust cannot emit a wasm GLOBAL.** `#[no_mangle] pub static` does not produce a global holding a value — wasm-ld exports the static's **address**: ``` declared: src_offset = 1024 kind_table_digest = 0x0123456789abcdef (i64) emitted: src_offset = 1048584 (i32) kind_table_digest = 1048576 (i32) ``` Both are addresses, and the digest comes out as **i32 where the ABI says i64**. That is exactly the state `guest_claiming_kind_table_as_i32` exists to pin, so **the host refuses a naive Rust guest** with `Reason::GrammarKindTableMismatch`. It fails closed, which is the right direction — but it fails. The obvious escape is blocked too: ``` core::arch::global_asm!(".globaltype kind_table_digest, i64, immutable", ...) error[E0658]: inline assembly is not stable yet on this architecture ``` Nightly would unblock it, but this project pins stable 1.98 and gates on `cargo check (MSRV 1.98)`, so requiring nightly of package authors is a real cost, not a detail. ### The way through, demonstrated rather than proposed A post-link step that appends the two globals and repoints the exports. Written and run against the actual rustc output: ``` globals 3 -> 5 global[3] i32 const = 1024 <- src_offset global[4] i64 const = 81985529216486895 <- kind_table_digest (0x123456789abcdef) EXPORTS: memory(memory) extract(func) src_offset(global 3) kind_table_digest(global 4) IMPORTS: 0 ``` 171 → **190 bytes**, zero imports preserved, and the patched module **still precompiles** (17 784-byte `.cwasm`). Nineteen bytes of section rewriting. This is routine for wasm toolchains — `wasm-bindgen` and `wasm-opt` both post-process — and it belongs in the SDK's build step rather than in each package author's hands. ### What this means for the plan **Phase 1 step 3 passes.** Nothing here invalidates Phases 2–4, and the size headroom means a real Ruby guest has room to be far larger than a spike. Phase 2 (the SDK) gains one requirement not previously written down: **the SDK must own a post-link step that injects `src_offset` and `kind_table_digest` as correctly-typed wasm globals**, because the guest cannot declare them itself on stable Rust. It should also assert the result — a guest that ships an i32 digest is refused by the host, so the SDK catching it at build time is the difference between a build error and a runtime refusal. Phase 1 steps 1 and 2 (reproducible `tree-sitter-ruby.wasm`, and full `(kind name, named) → id` agreement between the wasm grammar and the native crate) are **still unrun** and remain the cheap early kill — *"if these disagree, every emitted fact is silently wrong"*. Artefacts are scratch and not committed; the spike is reproducible from this comment.
Author
Member

Phases 1, 2 and 3 are DONE. Phase 4 is running. One issue-body claim was wrong.

Correction: "no new shim work" was false

The body says "tree-sitter-ruby's scanner includes only <string.h> and <wctype.h>, both already in tests/grammars/shim/, so the existing XAML grammar recipe should apply with no new shim work."

The headers are present; the symbols are not. The scanner calls strchr, which tests/grammars/shim/string.h did not declare, and the build fails outright. One added line. Recording it because this is the second time an issue-body claim on this ticket was measured on the wrong axis — the first was the ABI-registry check that missed name == src[name_span].

Phase 1 — steps 1 and 2, RUN

  • tests/grammars/tree-sitter-ruby.wasm, 2 125 126 bytes, sha256 c54cc209e4284bef45cf7ccbd3c0e6c38a900c2ae8a977bf63362a91d0c4e3d8, built by tests/grammars/build-tree-sitter-ruby.sh. Two independent build dirs, byte-identical.
  • Step 2, the cheap early kill: passed. crates/plugin-host/tests/ruby_kind_table.rs proves the wasm grammar and the native crate agree on every (kind name, named) → id and every field id — not the ~43 Ruby uses — with a different grammar version as the negative control.

A wasm-vs-native cost A/B also landed (grammar_ab.rs): ~1.5×, flat across three decades of file size. That is the number the whole "every language ships as a plugin" direction rests on, and it had never been measured. It ships a floor as well as a ceiling — every way of getting an A/B harness wrong makes the ratio smaller, so a ceiling alone could never catch the mistakes that file is prone to. The floor mutation goes red at 1.00×; the ceiling mutation did not.

Phase 2 — the SDK

crates/guest/: no_std, forbid(unsafe_code), zero dependencies. Cursor over the 16-byte pre-order stream, fact encoder, frame codec. Non-vacuity as specified: guest_cursor_equivalence.rs compares the cursor's traversal against tree_sitter::TreeCursor for every node of every fixture, not a spot check.

Plus the post-link global injector the spike comment said Phase 2 would need (crates/plugin-host/src/globals.rs, --inject-globals), because stable Rust cannot emit a wasm global and the host correctly refuses an i32 digest.

Phase 3 — the port

tests/packages/ruby/: 29 573-byte Rust guest extractor, the grammar, 5 fixtures. Step 7's expectations were generated from the builtin by a different agent than the port, with [[absent]] decoys, and step 6 could not edit them.

Axis B is green. Three-way: builtin-live == checked-in expectation == package worker output. All five expectations grade zero mismatches.

Step 8 (pack/approve/activate through the shipped binaries) is crates/daemon/tests/ruby_package_e2e.rs, and it was blocked by #103 — now fixed and closed: an operator can grant derived_names, plugin check --derived-names records the answer on the verdict, and the Rails file indexes with the derived type Post ref present.

The measurement Phase 4 has to adjudicate

Across 147 ruby-sinatra corpus files: 1254 symbols / 17 889 refs / 220 imports identical on both legs. But 883 rows differ, by exactly two systematic mechanisms:

  1. 880 synthesised self qualifiers on the package leg — the same mechanism the first correction comment already named as a RawRef.qualifier honesty problem ("exactly as written in source" is false in three of seven languages; ruby/sample.rb contains self zero times).
  2. a name_span projection difference.

This issue permits exactly two normalisations — shadow-path suffix and files.lang — and says anything else is "a delta with a named mechanism in a record file, or the gate is red". So Phase 4's central job is to decide, per mechanism and against the actual differing rows: defect in the port, or genuine pinned delta? A blanket normalisation that swallows both is precisely the vacuity this gate exists to prevent, and the pin must stay narrow enough that the required mutations still go red.

Phase 4 axes A (routing / ClaimTable, no indexing) and C (resolved projection via corpus/mod.rs's project()) are dispatched now, with the non-vacuity requirements from the body: producer identity read from extraction_components rather than an env var, absolute floors from the builtin leg on both, all six pool capabilities asserted granted, and the five named mutations run.

Follow-ups filed rather than scope-crept

#104 (re-grant does not reindex), #105 (aggregate cost of declining the grant), #106 (MCP plugin_add cannot grant it), #102 (private_class_method privatises the instance method — a real builtin defect this port found).

## Phases 1, 2 and 3 are DONE. Phase 4 is running. One issue-body claim was wrong. ### Correction: "no new shim work" was false The body says *"`tree-sitter-ruby`'s scanner includes only `<string.h>` and `<wctype.h>`, both already in `tests/grammars/shim/`, so the existing XAML grammar recipe should apply with no new shim work."* The headers are present; the **symbols** are not. The scanner calls `strchr`, which `tests/grammars/shim/string.h` did not declare, and the build fails outright. One added line. Recording it because this is the second time an issue-body claim on this ticket was measured on the wrong axis — the first was the ABI-registry check that missed `name == src[name_span]`. ### Phase 1 — steps 1 and 2, RUN - `tests/grammars/tree-sitter-ruby.wasm`, 2 125 126 bytes, sha256 `c54cc209e4284bef45cf7ccbd3c0e6c38a900c2ae8a977bf63362a91d0c4e3d8`, built by `tests/grammars/build-tree-sitter-ruby.sh`. Two independent build dirs, byte-identical. - **Step 2, the cheap early kill: passed.** `crates/plugin-host/tests/ruby_kind_table.rs` proves the wasm grammar and the native crate agree on **every** `(kind name, named) → id` and every field id — not the ~43 Ruby uses — with a different grammar version as the negative control. A wasm-vs-native cost A/B also landed (`grammar_ab.rs`): **~1.5×**, flat across three decades of file size. That is the number the whole "every language ships as a plugin" direction rests on, and it had never been measured. It ships a **floor as well as a ceiling** — every way of getting an A/B harness wrong makes the ratio *smaller*, so a ceiling alone could never catch the mistakes that file is prone to. The floor mutation goes red at 1.00×; the ceiling mutation did not. ### Phase 2 — the SDK `crates/guest/`: `no_std`, `forbid(unsafe_code)`, zero dependencies. Cursor over the 16-byte pre-order stream, fact encoder, frame codec. Non-vacuity as specified: `guest_cursor_equivalence.rs` compares the cursor's traversal against `tree_sitter::TreeCursor` for **every node** of every fixture, not a spot check. Plus the post-link global injector the spike comment said Phase 2 would need (`crates/plugin-host/src/globals.rs`, `--inject-globals`), because stable Rust cannot emit a wasm global and the host correctly refuses an i32 digest. ### Phase 3 — the port `tests/packages/ruby/`: 29 573-byte Rust guest extractor, the grammar, 5 fixtures. Step 7's expectations were generated from the **builtin** by a different agent than the port, with `[[absent]]` decoys, and step 6 could not edit them. **Axis B is green.** Three-way: builtin-live == checked-in expectation == package worker output. All five expectations grade **zero mismatches**. Step 8 (pack/approve/activate through the shipped binaries) is `crates/daemon/tests/ruby_package_e2e.rs`, and it was blocked by #103 — **now fixed and closed**: an operator can grant `derived_names`, `plugin check --derived-names` records the answer on the verdict, and the Rails file indexes with the derived `type Post` ref present. ### The measurement Phase 4 has to adjudicate Across 147 ruby-sinatra corpus files: **1254 symbols / 17 889 refs / 220 imports identical on both legs.** But **883 rows differ**, by exactly two systematic mechanisms: 1. **880 synthesised `self` qualifiers** on the package leg — the same mechanism the first correction comment already named as a `RawRef.qualifier` honesty problem ("exactly as written in source" is false in three of seven languages; `ruby/sample.rb` contains `self` zero times). 2. a **`name_span` projection** difference. This issue permits exactly **two** normalisations — shadow-path suffix and `files.lang` — and says anything else is "a delta with a named mechanism in a record file, or the gate is red". So Phase 4's central job is to decide, per mechanism and against the actual differing rows: **defect in the port, or genuine pinned delta?** A blanket normalisation that swallows both is precisely the vacuity this gate exists to prevent, and the pin must stay narrow enough that the required mutations still go red. Phase 4 axes **A** (routing / `ClaimTable`, no indexing) and **C** (resolved projection via `corpus/mod.rs`'s `project()`) are dispatched now, with the non-vacuity requirements from the body: producer identity read from `extraction_components` rather than an env var, absolute floors from the builtin leg on both, all six pool capabilities asserted granted, and the five named mutations run. ### Follow-ups filed rather than scope-crept #104 (re-grant does not reindex), #105 (aggregate cost of declining the grant), #106 (MCP `plugin_add` cannot grant it), #102 (`private_class_method` privatises the instance method — a real builtin defect this port found).
Author
Member

Phase 4 complete — all three axes. And the gate's own anti-vacuity clause caught a real defect on its first run.

The headline: the shipped package resolved zero

tests/packages/ruby 0.1.0 declared resolver = []. Every candidate relation joins temp.pool_admit, and grant_reserved hands all six capabilities to every reserved component — so a builtin never notices the gate exists. Measured through the shipped binaries over 156 files:

leg symbols refs imports resolved
builtin 1260 18450 231 2853
package, resolver = [] 1260 18450 231 0
package, six granted 1260 18450 231 2784

Symbols, refs and imports are identical in all three rows. A parity harness comparing extraction alone — which is what "compare canonical facts" would naturally mean — would have called this a perfect port. It took this issue's own clause, "all six pool capabilities asserted granted, so an inert package cannot compare 'no resolutions' against 'no resolutions'", to see it. That clause was written as a precaution against a hypothetical; it caught the actual shipped artifact.

Fixed in the lane: plugin.toml → 0.2.0 requesting the six, digest re-recorded, tests/packages/README.md and 80-package-authoring.md updated. Consequence worth noting: ruby_package_e2e step 2 had used same_file_candidate as its "wider than the request" probe — now legal — so it uses bridge_destination, with the reason written in.

The three axes

  • A — routing. crates/package/tests/ruby_claim_parity.rs, 5 tests. Two ClaimTables compared directly: no indexing, no database, no grammar. Proves the namespaces are structurally disjoint, that the package conflicts with no builtin (with an ext:rb control that must conflict), that every builtin-Ruby path has a package-claimed shadow, and pins the excess where the shadow map is not a bijection.
  • B — canonical facts. Already green: three-way builtin-live == checked-in expectation == package worker output, five expectations at zero mismatches.
  • C — resolved projection. crates/indexer/tests/ruby_package_parity.rs over all 156 ruby-domain ruby-sinatra files, both legs through index_path_with_packages, compared with corpus::project() — no second comparator, as required.
  • Cost leg with its own baseline (tests/corpus/ruby-package-cost.json); tests/corpus/baseline.json and cost-baseline.json both untouched. The SQLite trace machinery was extracted from corpus_cost.rs rather than copied, and corpus_cost rewired and re-verified green.

The pinned-delta decision

After the two permitted normalisations: 1260/1260 symbols identical on 9 of 10 columns, 18450/18450 ref sites identical, 231/231 imports identical. Five populations differ, each pinned by a predicate on the row, never a column exclusion:

mechanism rows verdict
qualifier="self" unspellable on the wire 876 not a port defect. The builtin fabricates a word absent from the source; the port declined to. Proved causally: patching the builtin to emit None took the delta 876→0 and the resolution delta 99→57
writer::qualified_name_separator builtin-lang allowlist 1260 symbols host defect — patching it made symbols identical on all 10 columns
lang IN ('php','ruby','csharp') same-dir arm 53 refs host defect — every one is builtin resolved_by == 12, package unresolved
POSITION_TYPE_KINDS_BY_LANG 19 refs host defect — patching it took 19→0
shadow renames Gemfile/*.gemspec 4 refs harness artifact, pinned by site

Sites 2–4 are one defect with three sites, and no package can close any of them: a manifest cannot declare a separator, "importlessness", or its position-type kinds. Filed as #112, with the note that POSITION_TYPE_KINDS_BY_LANG's own doc argues the keying is correct — which is why it is a product decision, not a bug fix. Cost findings are #113.

Also measured, and a trap worth recording: the shadow suffix must replace the extension, not append. Appending changes file_stem and costs 122 resolutions via temp.file_keys.

Mutations — all six executed, output verbatim in the record

# mutation result
1 delete a guest arm (tag::MODULE) RED at the floor, got 1122 — recorded honestly: the floor fires before the comparison
2 shift name_span +1 RED at the row comparison — "the two legs emit different REF SITES"
3 swap KIND_TAG[181]↔[184] RED, got 18279
4 remove the approval RED on identity; with the fixture's own guard also removed, RED at the file_contributions join, left 0, right 156 — before any projection
5 delete the shadow normalisation RED — "a package-leg column survived normalisation"
6 (added) guest qualified: true→false RED. And the same defect with the predicate widened into a column exclusion is GREEN — so the blanket normalisation demonstrably swallows a real port defect, shown rather than asserted

Mutation 6 is the one that matters for this issue's central question. The blanket-normalisation failure mode is not a worry any more; it is a measured, reproducible fact about this harness.

Axis A ran 5 more. Two are findings about the lane's own tests, recorded rather than re-aimed: ["rbx"]→["rb"] first failed at a parse expect because from_container validates, so the compared table now comes from unvalidated TOML; and dropping ext("rake") leaves the shadow tests green because the corpus is self-derived — caught one test over, by the key-list pin.

Two open items, both stated rather than buried

  1. Axis C is in no CI job, so it currently skips green. It needs --test ruby_package_parity --test ruby_package_cost in the corpus job's Corpus suites step (~4s and ~78s). Routed to the lane that owns .forgejo/ right now. This is the #108 defect in a second place — and worse: #108 is a floor of one where nine were needed; this grades zero and passes.
  2. The wall-clock band was blessed on a loaded box (load 6–25, two other suites running). Warm-up plus median-of-3 narrowed the spread from 145–442 to 306–358, and the blessed 350 with ceiling 525 / floor 227 is deliberately generous. It should be re-blessed isolated — an isolated run compared against an isolated run, never against a contended one. Noted in the record and in #113.

Gates: fmt, clippy -D warnings, rustdoc, corpus_ratchet (baseline md5 unmoved at 534084b856c22566c48e386bc41ed67e), precision_gate 7/7, ruby_package_parity, ruby_package_cost, ruby_package_e2e 4/4 — all green. Four failures in crates/mcp-server belong to concurrent lanes and are theirs to resolve.

_prdoc/records/84-P4-parity-deltas.md now exists with the verbatim mutation output; it had been cited 10 times, including inside operator-facing failure text, while not existing at all.

## Phase 4 complete — all three axes. And the gate's own anti-vacuity clause caught a real defect on its first run. ### The headline: the shipped package resolved **zero** `tests/packages/ruby` 0.1.0 declared `resolver = []`. Every candidate relation joins `temp.pool_admit`, and `grant_reserved` hands all six capabilities to every *reserved* component — so **a builtin never notices the gate exists**. Measured through the shipped binaries over 156 files: | leg | symbols | refs | imports | **resolved** | |---|---|---|---|---| | builtin | 1260 | 18450 | 231 | **2853** | | package, `resolver = []` | 1260 | 18450 | 231 | **0** | | package, six granted | 1260 | 18450 | 231 | **2784** | Symbols, refs and imports are **identical in all three rows.** A parity harness comparing extraction alone — which is what "compare canonical facts" would naturally mean — would have called this a perfect port. It took this issue's own clause, *"all six pool capabilities asserted granted, so an inert package cannot compare 'no resolutions' against 'no resolutions'"*, to see it. That clause was written as a precaution against a hypothetical; it caught the actual shipped artifact. Fixed in the lane: `plugin.toml` → 0.2.0 requesting the six, digest re-recorded, `tests/packages/README.md` and `80-package-authoring.md` updated. Consequence worth noting: `ruby_package_e2e` step 2 had used `same_file_candidate` as its "wider than the request" probe — now legal — so it uses `bridge_destination`, with the reason written in. ### The three axes - **A — routing.** `crates/package/tests/ruby_claim_parity.rs`, 5 tests. Two `ClaimTable`s compared directly: no indexing, no database, no grammar. Proves the namespaces are structurally disjoint, that the package conflicts with no builtin (with an `ext:rb` control that **must** conflict), that every builtin-Ruby path has a package-claimed shadow, and pins the excess where the shadow map is not a bijection. - **B — canonical facts.** Already green: three-way builtin-live == checked-in expectation == package worker output, five expectations at zero mismatches. - **C — resolved projection.** `crates/indexer/tests/ruby_package_parity.rs` over all 156 ruby-domain `ruby-sinatra` files, both legs through `index_path_with_packages`, compared with `corpus::project()` — no second comparator, as required. - **Cost leg** with **its own** baseline (`tests/corpus/ruby-package-cost.json`); `tests/corpus/baseline.json` and `cost-baseline.json` both untouched. The SQLite trace machinery was **extracted** from `corpus_cost.rs` rather than copied, and `corpus_cost` rewired and re-verified green. ### The pinned-delta decision After the two permitted normalisations: **1260/1260 symbols identical on 9 of 10 columns, 18450/18450 ref sites identical, 231/231 imports identical.** Five populations differ, each pinned by a **predicate on the row**, never a column exclusion: | mechanism | rows | verdict | |---|---|---| | `qualifier="self"` unspellable on the wire | 876 | **not a port defect.** The builtin fabricates a word absent from the source; the port declined to. Proved *causally*: patching the builtin to emit `None` took the delta 876→0 and the resolution delta 99→57 | | `writer::qualified_name_separator` builtin-lang allowlist | 1260 symbols | **host defect** — patching it made symbols identical on all 10 columns | | `lang IN ('php','ruby','csharp')` same-dir arm | 53 refs | **host defect** — every one is builtin `resolved_by == 12`, package unresolved | | `POSITION_TYPE_KINDS_BY_LANG` | 19 refs | **host defect** — patching it took 19→0 | | shadow renames `Gemfile`/`*.gemspec` | 4 refs | harness artifact, pinned by site | Sites 2–4 are **one defect with three sites**, and **no package can close any of them**: a manifest cannot declare a separator, "importlessness", or its position-type kinds. Filed as **#112**, with the note that `POSITION_TYPE_KINDS_BY_LANG`'s own doc argues the keying is *correct* — which is why it is a product decision, not a bug fix. Cost findings are **#113**. Also measured, and a trap worth recording: the shadow suffix must **replace** the extension, not append. Appending changes `file_stem` and costs 122 resolutions via `temp.file_keys`. ### Mutations — all six executed, output verbatim in the record | # | mutation | result | |---|---|---| | 1 | delete a guest arm (`tag::MODULE`) | **RED at the floor**, `got 1122` — recorded honestly: the floor fires *before* the comparison | | 2 | shift `name_span` +1 | **RED at the row comparison** — "the two legs emit different REF SITES" | | 3 | swap `KIND_TAG[181]`↔`[184]` | **RED**, `got 18279` | | 4 | remove the approval | **RED on identity**; with the fixture's own guard also removed, **RED at the `file_contributions` join**, `left 0, right 156` — before any projection | | 5 | delete the shadow normalisation | **RED** — "a package-leg column survived normalisation" | | 6 | *(added)* guest `qualified: true`→`false` | **RED**. And the same defect with the predicate widened into a column exclusion is **GREEN** — so the blanket normalisation demonstrably swallows a real port defect, shown rather than asserted | Mutation 6 is the one that matters for this issue's central question. The blanket-normalisation failure mode is not a worry any more; it is a measured, reproducible fact about this harness. Axis A ran 5 more. **Two are findings about the lane's own tests, recorded rather than re-aimed:** `["rbx"]→["rb"]` first failed at a parse `expect` because `from_container` validates, so the compared table now comes from unvalidated TOML; and dropping `ext("rake")` leaves the shadow tests green because the corpus is self-derived — caught one test over, by the key-list pin. ### Two open items, both stated rather than buried 1. **Axis C is in no CI job**, so it currently skips green. It needs `--test ruby_package_parity --test ruby_package_cost` in the `corpus` job's `Corpus suites` step (~4s and ~78s). Routed to the lane that owns `.forgejo/` right now. This is the #108 defect in a second place — and worse: #108 is a floor of one where nine were needed; this grades **zero** and passes. 2. **The wall-clock band was blessed on a loaded box** (load 6–25, two other suites running). Warm-up plus median-of-3 narrowed the spread from 145–442 to 306–358, and the blessed 350 with ceiling 525 / floor 227 is deliberately generous. It should be re-blessed isolated — an isolated run compared against an isolated run, never against a contended one. Noted in the record and in #113. Gates: fmt, clippy `-D warnings`, rustdoc, `corpus_ratchet` (baseline md5 unmoved at `534084b856c22566c48e386bc41ed67e`), `precision_gate` 7/7, `ruby_package_parity`, `ruby_package_cost`, `ruby_package_e2e` 4/4 — all green. Four failures in `crates/mcp-server` belong to concurrent lanes and are theirs to resolve. `_prdoc/records/84-P4-parity-deltas.md` now exists with the verbatim mutation output; it had been cited 10 times, including inside operator-facing failure text, while not existing at all.
Author
Member

Verdict, asked directly: YES — this closes. All three axes re-run in an independent lane at master 4f866e5, and BOTH open items from the phase-4 comment are closed in tree.

#75 turns on this, so the question was taken as "can it close", not "does it look done". Everything below was executed here, on a clean detached worktree of origin/master, not read off the previous comment.

The three axes, re-run

axis suite result
A — routing crates/package/tests/ruby_claim_parity.rs 5 passed, 0 failed, exit 0. Two ClaimTables compared directly: no indexing, no database, no grammar
B — canonical facts crates/daemon/tests/ruby_package_e2e.rs, COSI_E2E_LEG=daemon 5 passed, 0 failed, 115.8 s, exit 0 — including both derived_names grant arms
C — resolved projection crates/indexer/tests/ruby_package_parity.rs 1 passed, 185.1 s, exit 0, corpus[ruby_package_parity]: executed=1 unavailable=0 not_applicable=0 controls=4 (require=true)

executed=1, not a skip — run with COSI_CORPUS_DIR and COSI_CORPUS_REQUIRE=1 and --nocapture, because without them the suite reports executed=0 and PASSES, which is its own trap.

Axis C's pinned-delta line reads SelfQualifier: 834, which reconciles with the recorded 834 + 31 + 11 = 876 (ruby_package_parity.rs:995). No drift since the split by consequence.

Open item 1 — "Axis C is in no CI job, so it currently skips green" — CLOSED

.forgejo/workflows/ci.yml:935-936 now names --test ruby_package_parity --test ruby_package_cost in the corpus job's Corpus suites step, with package_cost_attribution beside them.

And the fix went further than the request, which is why this is closed rather than patched: the list is no longer maintained by hand. corpus_require_floor.rs::every_corpus_suite_runs_where_the_require_floor_applies fails if a file using corpus::Coverage is named by no job that sets COSI_CORPUS_REQUIRE=1. That is the generic form of the defect this issue's own comment named — "#108's defect at its limit: a floor of ZERO, passing green" — and the CI comment records that it caught a fifth suite (package_cost_attribution) within the hour of being written, before it shipped rather than after.

Open item 2 — "the wall-clock band was blessed on a loaded box" — CLOSED

tests/corpus/ruby-package-cost.json's current _blessed.reason records the re-measure and states the condition verbatim:

"MEASURED ISOLATED, as this gate's own refusal demands: load average 1.16, disk 58% — well under the ~88% above which this box is not a measurement in either direction."

The superseded reasons are kept rather than overwritten, so the two contended bands (load 22-35 and load 39, both of which said so out loud) are still readable beside it. That is the item this comment asked for — an isolated run compared against an isolated run.

The one thing that is red, and it is NOT this issue's

ruby_package_cost is currently refused at master, by its own condition gate: _blessed.schema is 61 and the binary is 62. That is the gate working exactly as designed — "a record taken under a different schema or a different package version is refused with re-MEASURE, never re-blessed on top of a stale reading" — and it belongs to whoever bumped the schema, not to the migration proof. Deliberately not blessed here, and not re-run either: it is a wall-clock-sensitive suite and this box was running a workspace build, so a reading taken now would be worth nothing in either direction.

Nothing about that red is a statement about whether the Ruby plugin migrated. Axis C — the axis that grades whether the two legs agree — is green.

What this issue set out to prove, and did

#80's anti-vacuity gate asked for "one complete existing compiled plugin" migrated into an external package path, with "aggregate-count similarity is not equivalence". What it got:

  • a real wasm32-unknown-unknown Rust guest against a real tree-sitter-ruby.wasm, both reproducible, with the kind-table agreement proved on every (kind name, named) → id rather than the ~43 Ruby uses;
  • three-way axis B (builtin-live == checked-in expectation == package worker output) at zero mismatches;
  • axis C over 153 real corpus files with one pinned mechanism left, adjudicated per-mechanism against differing rows and pinned by a predicate, never a column exclusion — and mutation 6 showed, rather than asserted, that the blanket normalisation swallows a real port defect;
  • eleven mutations run with verbatim output in _prdoc/records/84-P4-parity-deltas.md;
  • and the gate's own anti-vacuity clause caught a real defect in the shipped artifact on its first run (resolver = [], resolving zero against the builtin's 2853, with symbols/refs/imports identical — a parity harness comparing extraction alone would have called it perfect).

Three issue-body claims were measured wrong along the way and each was corrected in place rather than edited away: the ABI-registry check that missed name == src[name_span], the "no new shim work" claim (strchr was undeclared), and the assumption that a .wat guest's zero-import property transferred. That record is worth more than a clean ticket.

What is NOT closed by this, and now owns itself

  • #86 gap 4 — the fact ABI has no qualifier TEXT field. 876 rows, the only pinned delta left, and no manifest or host change can close it. #86's body has been corrected today to say so.
  • #166 — a packaged JavaScript loses 19.4% of all resolutions to Visibility::Unknown.
  • The cost baseline's schema-62 re-measure, above.

None of those is "the Ruby plugin did not migrate". They are what the migration MEASURED, which is what the gate was for.

Closing. #80 step 13 is satisfied; #75's remaining structure is #80 and #86.

🤖 Packaged-language lane, 2026-09-06, master 4f866e5, worktree /tmp/cosi-lane-pkg

## Verdict, asked directly: **YES — this closes.** All three axes re-run in an independent lane at master `4f866e5`, and BOTH open items from the phase-4 comment are closed in tree. #75 turns on this, so the question was taken as "can it close", not "does it look done". Everything below was executed here, on a clean detached worktree of `origin/master`, not read off the previous comment. ### The three axes, re-run | axis | suite | result | |---|---|---| | **A — routing** | `crates/package/tests/ruby_claim_parity.rs` | **5 passed, 0 failed**, exit 0. Two `ClaimTable`s compared directly: no indexing, no database, no grammar | | **B — canonical facts** | `crates/daemon/tests/ruby_package_e2e.rs`, `COSI_E2E_LEG=daemon` | **5 passed, 0 failed**, 115.8 s, exit 0 — including both `derived_names` grant arms | | **C — resolved projection** | `crates/indexer/tests/ruby_package_parity.rs` | **1 passed**, 185.1 s, exit 0, `corpus[ruby_package_parity]: executed=1 unavailable=0 not_applicable=0 controls=4 (require=true)` | `executed=1`, not a skip — run with `COSI_CORPUS_DIR` and `COSI_CORPUS_REQUIRE=1` and `--nocapture`, because without them the suite reports `executed=0` and PASSES, which is its own trap. Axis C's pinned-delta line reads `SelfQualifier: 834`, which reconciles with the recorded `834 + 31 + 11 = 876` (`ruby_package_parity.rs:995`). **No drift** since the split by consequence. ### Open item 1 — "Axis C is in no CI job, so it currently skips green" — **CLOSED** `.forgejo/workflows/ci.yml:935-936` now names `--test ruby_package_parity --test ruby_package_cost` in the `corpus` job's `Corpus suites` step, with `package_cost_attribution` beside them. And the fix went further than the request, which is why this is closed rather than patched: **the list is no longer maintained by hand.** `corpus_require_floor.rs::every_corpus_suite_runs_where_the_require_floor_applies` fails if a file using `corpus::Coverage` is named by no job that sets `COSI_CORPUS_REQUIRE=1`. That is the generic form of the defect this issue's own comment named — *"#108's defect at its limit: a floor of ZERO, passing green"* — and the CI comment records that it caught a **fifth** suite (`package_cost_attribution`) within the hour of being written, before it shipped rather than after. ### Open item 2 — "the wall-clock band was blessed on a loaded box" — **CLOSED** `tests/corpus/ruby-package-cost.json`'s current `_blessed.reason` records the re-measure and states the condition verbatim: > *"MEASURED ISOLATED, as this gate's own refusal demands: load average 1.16, disk 58% — well under the ~88% above which this box is not a measurement in either direction."* The superseded reasons are kept rather than overwritten, so the two contended bands (load 22-35 and load 39, both of which said so out loud) are still readable beside it. That is the item this comment asked for — an isolated run compared against an isolated run. ### The one thing that is red, and it is NOT this issue's `ruby_package_cost` is currently refused at master, by its own condition gate: `_blessed.schema` is **61** and the binary is **62**. That is the gate working exactly as designed — *"a record taken under a different schema or a different package version is refused with `re-MEASURE`, never re-blessed on top of a stale reading"* — and it belongs to whoever bumped the schema, not to the migration proof. **Deliberately not blessed here**, and not re-run either: it is a wall-clock-sensitive suite and this box was running a workspace build, so a reading taken now would be worth nothing in either direction. Nothing about that red is a statement about whether the Ruby plugin migrated. Axis C — the axis that grades whether the two legs agree — is green. ### What this issue set out to prove, and did #80's anti-vacuity gate asked for *"one complete existing compiled plugin"* migrated into an external package path, with *"aggregate-count similarity is not equivalence"*. What it got: - a real `wasm32-unknown-unknown` Rust guest against a real `tree-sitter-ruby.wasm`, both reproducible, with the kind-table agreement proved on **every** `(kind name, named) → id` rather than the ~43 Ruby uses; - three-way axis B (builtin-live == checked-in expectation == package worker output) at zero mismatches; - axis C over 153 real corpus files with **one** pinned mechanism left, adjudicated per-mechanism against differing rows and pinned by a **predicate**, never a column exclusion — and mutation 6 showed, rather than asserted, that the blanket normalisation swallows a real port defect; - eleven mutations run with verbatim output in `_prdoc/records/84-P4-parity-deltas.md`; - and the gate's own anti-vacuity clause caught a real defect in the shipped artifact on its first run (`resolver = []`, resolving **zero** against the builtin's 2853, with symbols/refs/imports **identical** — a parity harness comparing extraction alone would have called it perfect). Three issue-body claims were measured wrong along the way and each was corrected in place rather than edited away: the ABI-registry check that missed `name == src[name_span]`, the "no new shim work" claim (`strchr` was undeclared), and the assumption that a `.wat` guest's zero-import property transferred. That record is worth more than a clean ticket. ### What is NOT closed by this, and now owns itself - **#86 gap 4** — the fact ABI has no qualifier TEXT field. 876 rows, the only pinned delta left, and no manifest or host change can close it. #86's body has been corrected today to say so. - **#166** — a packaged JavaScript loses 19.4% of all resolutions to `Visibility::Unknown`. - **The cost baseline's schema-62 re-measure**, above. None of those is "the Ruby plugin did not migrate". They are what the migration MEASURED, which is what the gate was for. **Closing.** #80 step 13 is satisfied; #75's remaining structure is #80 and #86. 🤖 Packaged-language lane, 2026-09-06, master `4f866e5`, worktree `/tmp/cosi-lane-pkg`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#84
No description provided.