plugin ABI: the qualifier-TEXT gap (gaps 1 and 2 CLOSED; gap 3 is a #77 capability decision, not an ABI gap) #86

Open
opened 2026-09-01 11:23:15 +02:00 by buildagent · 7 comments
Member

BODY CORRECTED IN PLACE 2026-09-06 (packaged-language lane, master 4f866e5).
The original filing's three gaps have been re-measured against the tree. Gap 1 is closed, gap 2's premise was factually false, and gap 3 is not an ABI gap at all. A fourth gap — found by the #84 Ruby port and not predicted by the original analysis — is the only live one. The original text is preserved below the line, because the way it was wrong is the useful part: twice on this ticket a gap was measured on the wrong axis.

Status, as of 2026-09-06

gap state citation
1. derived refs have no marker CLOSED Ref::derived_name, crates/abi/src/record.rs:151. Grant path graded by ruby_package_e2e::a_rails_association_is_refused_for_want_of_the_derived_name_grant and …an_operator_can_grant_derived_names_and_the_rails_file_then_indexes — both green, 5/5, this lane
2. attr_start_line has no wire representation CLOSED — and the premise below is FALSE TAG_SYMBOL_ATTR_SPAN = 0x8001 at crates/abi/src/record.rs:17, and it is mapped: crates/indexer/src/packages.rs:4475 reads attr_start_line: s.attr_span.map(|b| pos.line_col(b.start as usize).line), with withhold_foreign_attr_lines at :4484 as its enforcement half
3. is_extension has no wire bit NOT AN ABI GAP. An unmade #77 capability decision tag TAG_SYMBOL_IS_EXTENSION = 0x8002 exists at crates/abi/src/record.rs:18 and decodes. The host writes literal is_extension: false at packages.rs:4481, deliberately, with the reason at :4462-4473
4. the fact ABI has no qualifier TEXT field OPEN — the only live one the wire carries a span, never a string; pinned in-tree at packages.rs:4869-4871 and graded by ruby_package_parity's SelfQualifier mechanism

Gap 2: strike the sentence

The original body says "packages::facts_to_extract hardcodes None because there is nowhere on the wire to carry it." That has been false since 51a72ee (2026-09-01). The mapping is live at packages.rs:4475. The sentence is struck rather than deleted because it was cited onward: it is why the migration table below listed rust/python/typescript/csharp as blocked, and they are not blocked by this.

Gap 3: a decision, not plumbing

Wiring the bit through would falsify pool_capability_registry's NeverDynamic("s.is_extension = 1") stance — that registry's registered structural justification IS the hard-coded false — and would admit a package to the C# extension-method candidate pool by setting one wire bit, with no grant. ext_cands is populated by one statement that consults nothing but the bit, so believing the bit is the authorisation.

Closing it is three coordinated edits mirroring derived_names exactly: a fourth [capabilities] key through manifest → HostPolicy → Grants → activation digest; facts_to_extract passing the bit; and the registry row moving from NeverDynamic to Gated. Step 2 without step 3 makes the registry lie.

It should be taken deliberately or recorded as declined, not left reading as unfinished ABI plumbing. Splitting it out is the right move; C# is blocked on it either way.

Gap 4: the qualifier TEXT field — where the work is

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

  • Ruby — the @ivar arm attaches qualifier = "self"; ruby/sample.rb contains self zero times.
  • C# — emit_implicit_member_access attaches qualifier = "this"; csharp/Sample.cs contains this 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, because there is no span to point at — the text does not exist in the source.

Measured, not predicted: 876 ref rows on the #84 Ruby parity leg (834 with no resolution consequence + 31 + 11; ruby_package_parity.rs:995). Adjudicated not a port defect and proved causally: patching crates/plugins/src/ruby.rs to emit None took the delta 876 → 0 and the resolution delta 99 → 57.

It is the only pinned delta left in ruby_package_parity — #112's three host gates went to zero on 2026-09-06 and #167's EcosystemMarker was deleted rather than pinned. Verified green in this lane at master 4f866e5: executed=1, controls=4, SelfQualifier: 834.

Shape of the fix, and it is a capability change and not only a wire one: a qualifier the source does not contain is authority of the same kind derived_names is, so it needs a fourth [capabilities] key through manifest → HostPolicy → Grants → activation digest, exactly as #103 shipped. That moves extraction_identity and re-extracts every claimed file on every project with the package enabled — a coupled landing with the guest rebuild, the wasm bytes, WASM_ARTIFACTS, the .digest, the plugin.toml version and the recorded expectations.

Revised migration table

language blocked by
ruby nothing structural — it has been migrated. Ships with the 876-row qualifier pin
php nothing measured
python nothing measured (gap 2 refuted)
rust gap 4, for join_use's assembled brace-import prefixes
typescript gap 4 is not its blocker; see #166 — Visibility::Unknown has no wire slot and a packaged JavaScript loses 19.4% of all resolutions (js-express, measured causally)
csharp gap 3 (the unmade capability decision) plus gap 4 for emit_implicit_member_access's fabricated "this"

#84 (the Ruby migration, done), #103 (the derived_names precedent to copy), #112 (closed — the three host gates), #166 (the TypeScript/JavaScript visibility residue, split out), #167 (closed), #80, #75.


ORIGINAL BODY, 2026-09-01 — preserved; gap 2's premise is false and gap 3 is misfiled as an ABI gap

Measured during #80's pre-release review and #84's candidate analysis. All three are additive to the wire format — no schema migration — and each blocks a specific set of languages from ever becoming a package.

Filed together because the long-term direction is that every language ships as a plugin, and these are what stand between "one reference package" and that.

1. Derived refs have no marker — blocks Ruby's Rails support

The ABI's strongest check is name == src[name_span], and it is right to be: it is what stops a package inventing a binding at a span it does not own.

But a plugin may legitimately emit a ref whose NAME is derived rather than copied. crates/plugins/src/ruby.rs's Rails association DSL reads has_many :posts and emits a type ref named Post — singularised and camelised — at the span of the literal :posts.

validate refuses it with fact.span_name_mismatch, and validation is all-or-nothing per file, so three offending rows cost eleven refs across two whole files.

The ABI already carves out exactly one derived kind (binding) by name. What it needs is a general marker: a ref that declares itself derived, so the span means "where this fact came from" rather than "the bytes of this name". The verifier's guarantee is preserved for every ref that does not claim it.

Blocks: Ruby (the #84 candidate). Any framework-aware extraction in any language — which is the whole "frameworks are plugins too" half of the direction.

2. attr_start_line has no wire representation at all — blocks four languages

STRUCK 2026-09-06 — THIS PARAGRAPH IS FALSE. facts_to_extract does not hardcode None; the mapping is at crates/indexer/src/packages.rs:4475 and has been since 51a72ee on 2026-09-01. rust/python/typescript/csharp are not blocked by this.

RawSymbol::attr_start_line exists in the row and is populated by four builtins — rust (8 sites), typescript (7), csharp (6), python (3) — and packages::facts_to_extract hardcodes None because there is nowhere on the wire to carry it.

So any package implementing one of those four either loses the field or ships a pinned delta on a column symbol_at and the diff-overlap query both read.

Fix: an optional tag (>= 0x8000, the existing skippable convention) plus one line in facts_to_extract. symbols.attr_start_line already exists — no migration. (Both halves shipped.)

3. is_extension has no wire bit — blocks C#

RE-DIAGNOSED 2026-09-06. The wire bit EXISTS — TAG_SYMBOL_IS_EXTENSION = 0x8002, crates/abi/src/record.rs:18 — and decodes. This is not an ABI gap; it is an unmade #77 capability decision. See the corrected section above.

Same shape, narrower: only csharp emits it (8 sites), and the resolver tier behind it is keyed on C# grammar knowledge, so closing it is a resolver change rather than only an ABI change.

Blocks: csharp, outright.

Why these are worth doing before a second migration, not during

#84's candidate table exists because two languages emit neither field:

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

So Ruby and PHP can migrate today (modulo gap 1), and nothing else can migrate at all until gaps 2 and 3 close. A migration that opens with "these two columns are pinned deltas" is a weaker proof than one where they are identical by construction.

#84 (Ruby migration, the first one), #85 (extraction serialization), #80. Record with the measurements: _prdoc/records/80-S43-migration-candidate.md, corrected in place after gap 1 was found by a test rather than by the analysis.

> **BODY CORRECTED IN PLACE 2026-09-06** (packaged-language lane, master `4f866e5`). > The original filing's three gaps have been re-measured against the tree. **Gap 1 is closed, gap 2's premise was factually false, and gap 3 is not an ABI gap at all.** A fourth gap — found by the #84 Ruby port and not predicted by the original analysis — is the only live one. The original text is preserved below the line, because the *way* it was wrong is the useful part: twice on this ticket a gap was measured on the wrong axis. ## Status, as of 2026-09-06 | gap | state | citation | |---|---|---| | **1. derived refs have no marker** | **CLOSED** | `Ref::derived_name`, `crates/abi/src/record.rs:151`. Grant path graded by `ruby_package_e2e::a_rails_association_is_refused_for_want_of_the_derived_name_grant` and `…an_operator_can_grant_derived_names_and_the_rails_file_then_indexes` — both green, 5/5, this lane | | **2. `attr_start_line` has no wire representation** | **CLOSED — and the premise below is FALSE** | `TAG_SYMBOL_ATTR_SPAN = 0x8001` at `crates/abi/src/record.rs:17`, **and it is mapped**: `crates/indexer/src/packages.rs:4475` reads `attr_start_line: s.attr_span.map(\|b\| pos.line_col(b.start as usize).line)`, with `withhold_foreign_attr_lines` at `:4484` as its enforcement half | | **3. `is_extension` has no wire bit** | **NOT AN ABI GAP.** An unmade #77 capability decision | tag `TAG_SYMBOL_IS_EXTENSION = 0x8002` **exists** at `crates/abi/src/record.rs:18` and decodes. The host writes literal `is_extension: false` at `packages.rs:4481`, deliberately, with the reason at `:4462-4473` | | **4. the fact ABI has no qualifier TEXT field** | **OPEN — the only live one** | the wire carries a **span, never a string**; pinned in-tree at `packages.rs:4869-4871` and graded by `ruby_package_parity`'s `SelfQualifier` mechanism | ### Gap 2: strike the sentence The original body says *"`packages::facts_to_extract` hardcodes `None` because there is nowhere on the wire to carry it."* **That has been false since `51a72ee` (2026-09-01).** The mapping is live at `packages.rs:4475`. The sentence is struck rather than deleted because it was cited onward: it is why the migration table below listed rust/python/typescript/csharp as blocked, and they are not blocked by this. ### Gap 3: a decision, not plumbing Wiring the bit through would falsify `pool_capability_registry`'s `NeverDynamic("s.is_extension = 1")` stance — **that registry's registered structural justification IS the hard-coded `false`** — and would admit a package to the C# extension-method candidate pool by setting one wire bit, with **no grant**. `ext_cands` is populated by one statement that consults nothing but the bit, so believing the bit *is* the authorisation. Closing it is three coordinated edits mirroring `derived_names` exactly: a fourth `[capabilities]` key through manifest → `HostPolicy` → `Grants` → activation digest; `facts_to_extract` passing the bit; and the registry row moving from `NeverDynamic` to `Gated`. **Step 2 without step 3 makes the registry lie.** It should be **taken deliberately or recorded as declined**, not left reading as unfinished ABI plumbing. Splitting it out is the right move; C# is blocked on it either way. ### Gap 4: the qualifier TEXT field — where the work is `RawRef.qualifier`'s own doc says the qualifier is *"exactly as written in source"*. Measured across seven languages that is **false in three**, by three independent mechanisms: - **Ruby** — the `@ivar` arm attaches `qualifier = "self"`; `ruby/sample.rb` contains `self` **zero** times. - **C#** — `emit_implicit_member_access` attaches `qualifier = "this"`; `csharp/Sample.cs` contains `this` **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, because **there is no span to point at** — the text does not exist in the source. **Measured, not predicted: 876 ref rows** on the #84 Ruby parity leg (`834` with no resolution consequence + `31` + `11`; `ruby_package_parity.rs:995`). Adjudicated **not a port defect** and proved causally: patching `crates/plugins/src/ruby.rs` to emit `None` took the delta **876 → 0** and the resolution delta **99 → 57**. It is the **only pinned delta left** in `ruby_package_parity` — #112's three host gates went to zero on 2026-09-06 and #167's `EcosystemMarker` was deleted rather than pinned. Verified green in this lane at master `4f866e5`: `executed=1, controls=4, SelfQualifier: 834`. **Shape of the fix**, and it is a capability change and not only a wire one: a qualifier the source does not contain is authority of the same kind `derived_names` is, so it needs a fourth `[capabilities]` key through manifest → `HostPolicy` → `Grants` → activation digest, exactly as #103 shipped. That moves `extraction_identity` and re-extracts every claimed file on every project with the package enabled — a coupled landing with the guest rebuild, the wasm bytes, `WASM_ARTIFACTS`, the `.digest`, the `plugin.toml` version and the recorded expectations. ### Revised migration table | language | blocked by | |---|---| | ruby | **nothing structural — it has been migrated.** Ships with the 876-row qualifier pin | | php | nothing measured | | python | nothing measured (gap 2 refuted) | | rust | gap 4, for `join_use`'s assembled brace-import prefixes | | typescript | gap 4 is not its blocker; see **#166** — `Visibility::Unknown` has no wire slot and a packaged JavaScript loses **19.4% of all resolutions** (`js-express`, measured causally) | | csharp | gap 3 (the unmade capability decision) **plus** gap 4 for `emit_implicit_member_access`'s fabricated `"this"` | ### Related #84 (the Ruby migration, done), #103 (the `derived_names` precedent to copy), #112 (closed — the three host gates), #166 (the TypeScript/JavaScript visibility residue, split out), #167 (closed), #80, #75. --- <details> <summary>ORIGINAL BODY, 2026-09-01 — preserved; gap 2's premise is false and gap 3 is misfiled as an ABI gap</summary> Measured during #80's pre-release review and #84's candidate analysis. All three are **additive** to the wire format — no schema migration — and each blocks a specific set of languages from ever becoming a package. Filed together because the long-term direction is that **every language ships as a plugin**, and these are what stand between "one reference package" and that. ## 1. Derived refs have no marker — blocks Ruby's Rails support The ABI's strongest check is `name == src[name_span]`, and it is right to be: it is what stops a package inventing a binding at a span it does not own. But a plugin may legitimately emit a ref whose NAME is derived rather than copied. `crates/plugins/src/ruby.rs`'s Rails association DSL reads `has_many :posts` and emits a `type` ref named **`Post`** — singularised and camelised — at the span of the literal `:posts`. `validate` refuses it with `fact.span_name_mismatch`, and **validation is all-or-nothing per file**, so three offending rows cost **eleven refs across two whole files**. The ABI already carves out exactly one derived kind (`binding`) **by name**. What it needs is a general marker: a ref that declares itself derived, so the span means "where this fact came from" rather than "the bytes of this name". The verifier's guarantee is preserved for every ref that does not claim it. **Blocks:** Ruby (the #84 candidate). Any framework-aware extraction in any language — which is the whole "frameworks are plugins too" half of the direction. ## 2. `attr_start_line` has no wire representation at all — blocks four languages > **STRUCK 2026-09-06 — THIS PARAGRAPH IS FALSE.** `facts_to_extract` does not hardcode `None`; the mapping is at `crates/indexer/src/packages.rs:4475` and has been since `51a72ee` on 2026-09-01. rust/python/typescript/csharp are **not** blocked by this. ~~`RawSymbol::attr_start_line` exists in the row and is populated by four builtins — **rust (8 sites), typescript (7), csharp (6), python (3)** — and `packages::facts_to_extract` hardcodes `None` because there is nowhere on the wire to carry it.~~ ~~So any package implementing one of those four either loses the field or ships a pinned delta on a column `symbol_at` and the diff-overlap query both read.~~ **Fix:** an optional tag (`>= 0x8000`, the existing skippable convention) plus one line in `facts_to_extract`. `symbols.attr_start_line` already exists — **no migration**. *(Both halves shipped.)* ## 3. `is_extension` has no wire bit — blocks C# > **RE-DIAGNOSED 2026-09-06.** The wire bit EXISTS — `TAG_SYMBOL_IS_EXTENSION = 0x8002`, `crates/abi/src/record.rs:18` — and decodes. This is not an ABI gap; it is an unmade #77 capability decision. See the corrected section above. ~~Same shape, narrower: only csharp emits it (8 sites), and the resolver tier behind it is keyed on C# grammar knowledge, so closing it is a resolver change rather than only an ABI change.~~ **Blocks:** csharp, outright. ## Why these are worth doing before a second migration, not during #84's candidate table exists because two languages emit neither field: | language | plugin LOC | emits `attr_start_line`? | emits `is_extension`? | |---|---:|---|---| | **ruby** | 1859 | no (0/5 sites) | no (0/5 sites) | | php | 1984 | no (0) | no (0) | | python | 2049 | **yes (3)** | no | | rust | 3005 | **yes (8)** | no | | typescript | 3217 | **yes (7)** | no | | csharp | 3336 | **yes (6)** | **yes (8)** | So Ruby and PHP can migrate today (modulo gap 1), and **nothing else can migrate at all** until gaps 2 and 3 close. A migration that opens with "these two columns are pinned deltas" is a weaker proof than one where they are identical by construction. ## Related #84 (Ruby migration, the first one), #85 (extraction serialization), #80. Record with the measurements: `_prdoc/records/80-S43-migration-candidate.md`, corrected in place after gap 1 was found by a test rather than by the analysis. </details>
Author
Member

The ABI half of all three gaps SHIPPED on 2026-09-01/02 — this issue was stale

Audited before writing anything. All three landed in 51a72ee ("three ABI facts a real language plugin needs") and 1d90c3a ("a project grants derived names; a package no longer holds them"). This issue's own premise — "packages::facts_to_extract hardcodes None" — has been false for two days. Same hygiene problem as #81.

What exists, verified by reading it:

  • Gap 1 — Ref::derived_name, bit 2 of TAG_REF's presence mask, a required record on purpose: a skipped flag would change a verdict, not reduce detail. The old by-name binding carve-out is gone; the check is now if !derived_name { name == src[name_span] }. Gated by a split grant word (Grants { roles, derived_names }) whose From<u32> grants roles only, so all 39 legacy call sites deny. The role bit (19) sits outside GRANTABLE_ROLES, so it is unforgeable in both directions, and it is folded into the activation digest.
  • Gap 2 — TAG_SYMBOL_ATTR_SPAN (0x8001), carrying a span, not a line (positions are derived parent-side), refused when sp.start > decl.start.
  • Gap 3 — TAG_SYMBOL_IS_EXTENSION (0x8002), presence-as-value so false has exactly one encoding.

What was NOT done, and it was the half that matters

The consumer half was completely ungraded. a_validated_frame_becomes_the_same_shape_a_builtin_plugin_returns asserted attr_start_line == None and !is_extension on a frame that emitted neither annotation record — vacuous with respect to both mappings — and its comment still claimed "the wire has no field for" them.

Now closed by an_attribute_span_becomes_a_line_and_the_extension_bit_is_dropped. Its fixture puts the attribute on line 3 at byte 18 and the declaration on line 4, so None, the byte offset, and the declaration's line are three distinguishable wrong answers.

The measured survivors are why this mattered. Applying both mutations together — restore gap 2's attr_start_line: None, and flip gap 3's deliberate drop to is_extension: s.is_extension — left the entire five-crate suite green. A package could have entered the C# extension-method candidate pool with no capability gate, and nothing would have said so.

mutation result
attr_start_line: None (the pre-#86 hard-code) RED left: None right: Some(3)
byte offset passed through RED left: Some(18) right: Some(3)
derive from the declaration instead of the attribute RED left: Some(4) right: Some(3)
stamp every symbol RED ×2, including the anti-vacuity arm
is_extension: s.is_extension RED — the pool-entry gate
the ABI boundary arms themselves RED — or the drop below is vacuous

Gap 3 end-to-end needs a CAPABILITY DECISION, not a resolver change

ext_cands is populated by one statement that consults nothing about extension-ness beyond the bit:

INSERT INTO temp.ext_cands
SELECT ... FROM symbols s WHERE s.is_extension = 1 AND s.name IN (SELECT name FROM temp.ext_calls)

Reachability, uniqueness and visibility are all language-agnostic, so the SQL does not need to change to admit a package's rows. What blocks it is that the bit is a conclusion, not evidence: nothing on the wire says "this method's first parameter is a this parameter", so believing it means authorising a package to place itself in a binding pool.

Closing it is three coordinated edits mirroring derived_names exactly — a fourth [capabilities] key through manifest → HostPolicy → Grants → digest bit; facts_to_extract passing the bit; and pool_capability_registry moving ext_cands from NeverDynamic("s.is_extension = 1") to Gated(...), because that registry's registered justification IS the hard-coded false — step 2 without step 3 makes the registry lie. influence.rs and _prdoc/guides/80-capability-review.md repeat the same justification.

Recommendation: not now. It is a #77 language-profile capability decision, and the ABI half is what this issue asked for. The new test pins the current stance so the flip cannot happen by accident.

Remaining scope

Gaps 1 and 2 are complete and now graded. Gap 3's ABI half is complete; its end-to-end enablement is a capability decision that should be split out rather than left implying this issue is unfinished.

## The ABI half of all three gaps SHIPPED on 2026-09-01/02 — this issue was stale Audited before writing anything. All three landed in `51a72ee` ("three ABI facts a real language plugin needs") and `1d90c3a` ("a project grants derived names; a package no longer holds them"). **This issue's own premise — "`packages::facts_to_extract` hardcodes `None`" — has been false for two days.** Same hygiene problem as #81. What exists, verified by reading it: - **Gap 1** — `Ref::derived_name`, bit 2 of `TAG_REF`'s presence mask, a **required** record on purpose: a skipped flag would change a verdict, not reduce detail. The old by-name `binding` carve-out is **gone**; the check is now `if !derived_name { name == src[name_span] }`. Gated by a split grant word (`Grants { roles, derived_names }`) whose `From<u32>` grants roles only, so all 39 legacy call sites deny. The role bit (19) sits **outside `GRANTABLE_ROLES`**, so it is unforgeable in both directions, and it is folded into the activation digest. - **Gap 2** — `TAG_SYMBOL_ATTR_SPAN` (`0x8001`), carrying a **span, not a line** (positions are derived parent-side), refused when `sp.start > decl.start`. - **Gap 3** — `TAG_SYMBOL_IS_EXTENSION` (`0x8002`), **presence-as-value** so `false` has exactly one encoding. ## What was NOT done, and it was the half that matters **The consumer half was completely ungraded.** `a_validated_frame_becomes_the_same_shape_a_builtin_plugin_returns` asserted `attr_start_line == None` and `!is_extension` on a frame that emitted **neither annotation record** — vacuous with respect to both mappings — and its comment still claimed "the wire has no field for" them. Now closed by `an_attribute_span_becomes_a_line_and_the_extension_bit_is_dropped`. Its fixture puts the attribute on **line 3 at byte 18** and the declaration on **line 4**, so `None`, the byte offset, and the declaration's line are three distinguishable wrong answers. **The measured survivors are why this mattered.** Applying both mutations together — restore gap 2's `attr_start_line: None`, and flip gap 3's deliberate drop to `is_extension: s.is_extension` — left the **entire five-crate suite green**. A package could have entered the C# extension-method candidate pool **with no capability gate**, and nothing would have said so. | mutation | result | |---|---| | `attr_start_line: None` (the pre-#86 hard-code) | RED `left: None right: Some(3)` | | byte offset passed through | RED `left: Some(18) right: Some(3)` | | derive from the declaration instead of the attribute | RED `left: Some(4) right: Some(3)` | | stamp every symbol | RED ×2, including the anti-vacuity arm | | `is_extension: s.is_extension` | RED — the pool-entry gate | | the ABI boundary arms themselves | RED — or the drop below is vacuous | ## Gap 3 end-to-end needs a CAPABILITY DECISION, not a resolver change `ext_cands` is populated by one statement that consults **nothing** about extension-ness beyond the bit: ```sql INSERT INTO temp.ext_cands SELECT ... FROM symbols s WHERE s.is_extension = 1 AND s.name IN (SELECT name FROM temp.ext_calls) ``` Reachability, uniqueness and visibility are all language-agnostic, so the SQL does not need to change to admit a package's rows. What blocks it is that **the bit is a conclusion, not evidence**: nothing on the wire says "this method's first parameter is a `this` parameter", so believing it means authorising a package to place itself in a binding pool. Closing it is three coordinated edits mirroring `derived_names` exactly — a fourth `[capabilities]` key through manifest → `HostPolicy` → `Grants` → digest bit; `facts_to_extract` passing the bit; and `pool_capability_registry` moving `ext_cands` from `NeverDynamic("s.is_extension = 1")` to `Gated(...)`, **because that registry's registered justification IS the hard-coded `false`** — step 2 without step 3 makes the registry lie. `influence.rs` and `_prdoc/guides/80-capability-review.md` repeat the same justification. **Recommendation: not now.** It is a #77 language-profile capability decision, and the ABI half is what this issue asked for. The new test pins the current stance so the flip cannot happen by accident. ## Remaining scope Gaps 1 and 2 are complete and now graded. Gap 3's ABI half is complete; its end-to-end enablement is a capability decision that should be split out rather than left implying this issue is unfinished.
dhoyer referenced this issue from a commit 2026-09-04 07:02:49 +02:00
Author
Member

Status after v0.26.0 — two of three closed, the third deliberately left open

Gap 1 (derived refs have no marker) — CLOSED. A ref can now declare itself derived, so its span means "where this fact came from" rather than "the bytes of this name". The verifier's name == src[name_span] guarantee is preserved for every ref that does not claim it. This unblocks Ruby's Rails association DSL (has_many :posts → a type ref named Post at the span of :posts) and framework-aware extraction generally.

Gap 2 (attr_start_line has no wire representation) — CLOSED. Optional tag on the existing skippable convention, plus the line in facts_to_extract. No migration; symbols.attr_start_line already existed.

The consumer half had been entirely ungraded, which is worth recording: attr_start_line: None and the deliberate is_extension drop could BOTH be reverted and the whole five-crate suite stayed green. A package could have entered the C# extension-method candidate pool with no capability gate and nothing would have failed.

Gap 3 (is_extension has no wire bit) — OPEN, and staying open on purpose.

Admitting the bit means authorising a package into the C# extension-method binding pool — the pool SQL consults nothing but the bit, so believing it is the authorisation. That is a capability decision, not a mapping one, and it is left as one rather than smuggled in as an ABI addition. C# remains blocked on it, as this issue says.

Also shipped alongside, from #87

A guest can now map a kind NAME to a kind id without a new host call. Guests run with an empty import list — that emptiness is what makes fork, open and connect inexpressible — so a guest cannot ask "what number is function_item?". It now asserts and the host checks: the guest exports one immutable kind_table_digest, and the worker enumerates the grammar it just loaded, in the same process whose tree::serialize writes those ids, refusing on mismatch with exit 24 and printing the number the guest should have carried.

Every id is proven rather than sampled, on two axes — because a parity test cannot grade code that both its sides run. Reading node_kind_is_visible where node_kind_is_named belongs survived the wasm-vs-native comparison; the Node-API oracle on a real parse is what kills it. The zero-import property is measured by counting Module::imports(), not asserted in prose.

What this means for the migration table

Ruby and PHP could already migrate modulo gap 1; that caveat is now gone. Python, Rust and TypeScript are unblocked by gap 2. C# alone still cannot migrate, and the reason is a capability decision that has not been made rather than a missing wire tag.

## Status after v0.26.0 — two of three closed, the third deliberately left open **Gap 1 (derived refs have no marker) — CLOSED.** A ref can now declare itself derived, so its span means "where this fact came from" rather than "the bytes of this name". The verifier's `name == src[name_span]` guarantee is preserved for every ref that does not claim it. This unblocks Ruby's Rails association DSL (`has_many :posts` → a `type` ref named `Post` at the span of `:posts`) and framework-aware extraction generally. **Gap 2 (`attr_start_line` has no wire representation) — CLOSED.** Optional tag on the existing skippable convention, plus the line in `facts_to_extract`. No migration; `symbols.attr_start_line` already existed. The consumer half had been **entirely ungraded**, which is worth recording: `attr_start_line: None` and the deliberate `is_extension` drop could BOTH be reverted and the whole five-crate suite stayed green. A package could have entered the C# extension-method candidate pool with no capability gate and nothing would have failed. **Gap 3 (`is_extension` has no wire bit) — OPEN, and staying open on purpose.** Admitting the bit means authorising a package into the C# extension-method **binding pool** — the pool SQL consults nothing but the bit, so believing it *is* the authorisation. That is a capability decision, not a mapping one, and it is left as one rather than smuggled in as an ABI addition. C# remains blocked on it, as this issue says. ## Also shipped alongside, from #87 A guest can now map a kind NAME to a kind id without a new host call. Guests run with an **empty import list** — that emptiness is what makes `fork`, `open` and `connect` inexpressible — so a guest cannot ask "what number is `function_item`?". It now asserts and the host checks: the guest exports one immutable `kind_table_digest`, and the worker enumerates the grammar it just loaded, **in the same process whose `tree::serialize` writes those ids**, refusing on mismatch with exit 24 and printing the number the guest should have carried. Every id is proven rather than sampled, on two axes — because a parity test cannot grade code that both its sides run. Reading `node_kind_is_visible` where `node_kind_is_named` belongs **survived** the wasm-vs-native comparison; the Node-API oracle on a real parse is what kills it. The zero-import property is measured by counting `Module::imports()`, not asserted in prose. ## What this means for the migration table Ruby and PHP could already migrate modulo gap 1; that caveat is now gone. Python, Rust and TypeScript are unblocked by gap 2. **C# alone still cannot migrate**, and the reason is a capability decision that has not been made rather than a missing wire tag.
Author
Member

A fourth ABI gap, now measured against a real port rather than predicted: the fact ABI has no qualifier TEXT field.

The #84 Ruby migration measured this at scale. RawRef.qualifier's own doc says the qualifier is "exactly as written in source". Across the seven languages that is false in three, by three independent mechanisms:

  • Ruby — the @ivar arm attaches qualifier = "self"; ruby/sample.rb contains self zero times.
  • C# — emit_implicit_member_access attaches qualifier = "this"; csharp/Sample.cs contains this 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, because there is no span to point at — the text does not exist in the source.

What the port measured

876 rows. That is the single largest delta between the builtin and packaged Ruby legs, and it was adjudicated as not a port defect: the builtin fabricates a word absent from the source, and the packaged guest declined to, because the wire has no way to say "this qualifier is derived rather than quoted".

Proved causally rather than argued: patching crates/plugins/src/ruby.rs to emit None took the delta 876 → 0, and the resolution delta 99 → 57.

So this is not a cosmetic difference. It costs a packaged language real resolutions, and no manifest can close it — the same shape as #112's three language gates.

Why it belongs here

This issue already carries attr_start_line (no wire slot, blocks rust/python/typescript/csharp) and is_extension (blocks csharp). This is the third of the same kind, and it is the one with a measured row count behind it.

The shape of the fix is the one #86 already prefers: a derived-fact marker generalising the carve-out that exists for binding, applied to qualifiers — one mechanism closing the class, rather than a per-language exception. #103 shipped exactly that pattern for derived names (derived_names as a requested, granted, fail-closed authority with a measured witness), so there is now a working precedent to copy rather than a design to invent.

Noting also that #86's framing — "three gaps that block migrating any language other than Ruby or PHP" — is now partly outdated in a useful direction: Ruby has been migrated, and this gap was the largest thing the migration hit. It is a prerequisite for migrating C# and Rust, and a smaller one for Ruby.

## A fourth ABI gap, now measured against a real port rather than predicted: **the fact ABI has no qualifier TEXT field.** The #84 Ruby migration measured this at scale. `RawRef.qualifier`'s own doc says the qualifier is *"exactly as written in source"*. Across the seven languages that is **false in three**, by three independent mechanisms: - **Ruby** — the `@ivar` arm attaches `qualifier = "self"`; `ruby/sample.rb` contains `self` **zero** times. - **C#** — `emit_implicit_member_access` attaches `qualifier = "this"`; `csharp/Sample.cs` contains `this` **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, because there is no span to point at — the text does not exist in the source. ### What the port measured **876 rows.** That is the single largest delta between the builtin and packaged Ruby legs, and it was adjudicated as **not a port defect**: the builtin fabricates a word absent from the source, and the packaged guest declined to, because the wire has no way to say "this qualifier is derived rather than quoted". Proved causally rather than argued: patching `crates/plugins/src/ruby.rs` to emit `None` took the delta **876 → 0**, and the resolution delta **99 → 57**. So this is not a cosmetic difference. It costs a packaged language real resolutions, and **no manifest can close it** — the same shape as #112's three language gates. ### Why it belongs here This issue already carries `attr_start_line` (no wire slot, blocks rust/python/typescript/csharp) and `is_extension` (blocks csharp). This is the third of the same kind, and it is the one with a measured row count behind it. The shape of the fix is the one #86 already prefers: a **derived-fact marker generalising the carve-out that exists for `binding`**, applied to qualifiers — one mechanism closing the class, rather than a per-language exception. #103 shipped exactly that pattern for derived *names* (`derived_names` as a requested, granted, fail-closed authority with a measured witness), so there is now a working precedent to copy rather than a design to invent. Noting also that #86's framing — "three gaps that block migrating any language other than Ruby or PHP" — is now partly outdated in a useful direction: **Ruby has been migrated**, and this gap was the largest thing the migration hit. It is a prerequisite for migrating C# and Rust, and a smaller one for Ruby.
Author
Member

Re-measured 2026-09-05 by an independent pluggability review — one of the three gaps is refuted, and the real blocker is not in this issue

Gap status

gap status
attr_start_line REFUTED. TAG_SYMBOL_ATTR_SPAN (0x8001) exists and facts_to_extract maps it (crates/indexer/src/packages.rs:4349-4517), graded with three run mutations. rust/python/typescript/csharp are not blocked by this.
is_extension Confirmed, re-diagnosed. The wire tag (0x8002) exists and decodes. The host writes literal false, deliberately: admitting the bit is the authorisation into the C# extension-method binding pool. That makes it an unmade capability decision (#77), not a missing slot.
qualifier text Confirmed, and it is now the live gap.

The qualifier-text gap, causally proved

The wire carries qualifier_span/receiver_span — byte ranges only. A qualifier that is not a contiguous substring of the file is inexpressible. Three languages fabricate one: ruby "self", csharp "this", and rust's join_use assembling std::sync::atomic from nested braces.

The 876 rows are real and the causation is established, not inferred: crates/indexer/tests/ruby_package_parity.rs:848 pins it as an executable predicate (mutation-tested against being widened into a column exclusion), and _prdoc/records/84-P4-parity-deltas.md records that patching ruby.rs to None took 876 → 0 and the resolution delta 99 → 57.

Scoped to the fact ABI alone: php, python, typescript/javascript are clean; ruby ships with the 876 pin; rust ships with a recall delta on brace-imports; C# alone has a field the host refuses to carry.

Two things NOT in this issue that matter more

1. Column parity is zero for all six, and the cause is host-side, not ABI. Three allowlists keyed on builtin language ids miss any packaged language regardless of its plugin — that is #112, and it is the highest-leverage fix available: writer.rs:883's qualified_name_separator ends _ => None, and a package's files.lang always contains /, so every packaged symbol gets qualified_name = NULL (1260/1260 rows) — which is exactly what tier 1Q and TYPE_FQN join on. One generic gate change unblocks every future package, not one language.

2. BUILTIN_CLAIMS refuses a builtin's extension at pack time, upstream of the ABI entirely — see #87, where the owner has now decided on an operator-consented override.

New, belongs here

TypeScript emits Visibility::Unknown (crates/plugins/src/typescript.rs:955), which has no wire slot, and abi_projection.rs:613 swallows it with unwrap_or(2) — silently rewriting unknown → file. In a file whose own doc records that its previous defect was "three silent continues". The recall cost was not measured (the reviewer ran nothing).

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

## Re-measured 2026-09-05 by an independent pluggability review — one of the three gaps is refuted, and the real blocker is not in this issue ### Gap status | gap | status | |---|---| | `attr_start_line` | **REFUTED.** `TAG_SYMBOL_ATTR_SPAN` (0x8001) exists and `facts_to_extract` maps it (`crates/indexer/src/packages.rs:4349-4517`), graded with three run mutations. rust/python/typescript/csharp are **not** blocked by this. | | `is_extension` | **Confirmed, re-diagnosed.** The wire tag (0x8002) exists and decodes. The **host** writes literal `false`, deliberately: admitting the bit *is* the authorisation into the C# extension-method binding pool. That makes it an unmade capability decision (#77), not a missing slot. | | qualifier **text** | **Confirmed, and it is now the live gap.** | ### The qualifier-text gap, causally proved The wire carries `qualifier_span`/`receiver_span` — **byte ranges only**. A qualifier that is not a contiguous substring of the file is inexpressible. Three languages fabricate one: ruby `"self"`, csharp `"this"`, and rust's `join_use` assembling `std::sync::atomic` from nested braces. The 876 rows are real and the causation is established, not inferred: `crates/indexer/tests/ruby_package_parity.rs:848` pins it as an executable predicate (mutation-tested against being widened into a column exclusion), and `_prdoc/records/84-P4-parity-deltas.md` records that patching `ruby.rs` to `None` took **876 → 0** and the resolution delta **99 → 57**. **Scoped to the fact ABI alone:** php, python, typescript/javascript are clean; ruby ships with the 876 pin; rust ships with a recall delta on brace-imports; C# alone has a field the host refuses to carry. ### Two things NOT in this issue that matter more **1. Column parity is zero for all six**, and the cause is host-side, not ABI. Three allowlists keyed on builtin language ids miss any packaged language regardless of its plugin — that is **#112**, and it is the highest-leverage fix available: `writer.rs:883`'s `qualified_name_separator` ends `_ => None`, and a package's `files.lang` always contains `/`, so **every packaged symbol gets `qualified_name = NULL` (1260/1260 rows)** — which is exactly what tier 1Q and TYPE_FQN join on. One generic gate change unblocks every future package, not one language. **2.** `BUILTIN_CLAIMS` refuses a builtin's extension at **pack time, upstream of the ABI entirely** — see #87, where the owner has now decided on an operator-consented override. ### New, belongs here TypeScript emits `Visibility::Unknown` (`crates/plugins/src/typescript.rs:955`), which **has no wire slot**, and `abi_projection.rs:613` swallows it with `unwrap_or(2)` — silently rewriting `unknown` → `file`. In a file whose own doc records that its previous defect was "three silent `continue`s". The recall cost was not measured (the reviewer ran nothing). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

Re-measured against a real port, 2026-09-05 — the qualifier-text gap is now the ONLY remaining product delta between a packaged language and a builtin one

The previous comment said column parity was zero for all six and that the cause was host-side (#112), not the ABI. #112 is now fixed, so this issue's gap list can be stated against a clean background for the first time.

ruby_package_parity, after the fix, over 156 ruby-sinatra files, 1260 symbols and 18,450 ref sites:

mechanism rows owner
QualifiedNameLangGate 0 (was 1260) #112, closed
SameDirLangGate 0 (was 53) #112, closed
TypePositionLangGate 0 (was 19) #112, closed
SelfQualifier — the fact ABI has no qualifier TEXT 876 this issue
EcosystemMarker 4 the harness's shadow extension

Every symbol column is now identical row for row — the parity suite's symbol loop asserts diff.is_empty() and carries no pinned mechanism at all. 876 ref rows and one harness artifact are what is left.

So the framing changes: this is no longer "one of several ABI gaps beside several host gaps". The host gaps are closed, and the qualifier-text gap is the single thing standing between a packaged Ruby and a builtin one. It is also, per the earlier causal experiment (ruby.rs patched to None took 876 → 0 and the resolution delta 99 → 57), the one with a measured recall cost that no manifest and no host change can close — unlike #112's three, which turned out to be one gate.

Gap status, restated

gap status
attr_start_line REFUTED, as recorded above. TAG_SYMBOL_ATTR_SPAN (0x8001) exists and facts_to_extract maps it, graded with three run mutations.
is_extension Confirmed, and it is a #77 capability decision, not a missing slot. The wire tag (0x8002) decodes; the host writes literal false because admitting the bit is the authorisation into the C# extension-method binding pool.
qualifier TEXT Confirmed, and now the only live one.

On the TypeScript Visibility::Unknown finding in the previous comment

Re-checked, and it needs correcting in one direction and sharpening in the other.

The unwrap_or(2) is not in production and is not undocumented. It is crates/plugins/tests/abi_projection.rs — a test harness — and its comment says the mapping is deliberate: unknown is absent from code_index_abi::VISIBILITIES on purpose, DEFAULT_VISIBILITY is VisibilityId(2) = file, and registry.rs states the reason at length: "'not extracted, so treat as widely visible' is precisely the authority a third party may not assert." a_packaged_extractor_cannot_claim_unknown_visibility grades it. Calling it a silent swallow was wrong.

The residue underneath it is real, and it is a fourth ABI gap in this issue's own sense. core::raw::Visibility's own note names the asymmetry: Unknown pools as Exported for a compiled plugin and defaults to File for a packaged one. Those are opposite ends of the visibility lattice. typescript.rs:955 returns Unknown for a CommonJS file whose exports the plugin could not detect — and its comment says exactly why that must not become File: "mis-marking those File would silently drop real cross-file resolutions."

So a packaged TypeScript would take precisely the loss that arm exists to avoid. Not a bug in the mapping — the mapping is the correct least-authority default — but a capability shape the ABI cannot express: "I looked and could not tell" is a third state, and the wire has only "exported" and the four it enumerates.

The recall cost is still unmeasured, and I did not measure it either. Stated rather than implied. The measurement that settles it is one query on an indexed js-express or ts-zod: SELECT COUNT(*) FROM symbols WHERE lang IN ('javascript','typescript') AND visibility = 'unknown', and then how many refs currently resolve to those rows. If it is near zero the gap is theoretical and TypeScript can port; if it is not, it is a blocker of the same kind as is_extension is for C#, and it should be split out rather than left as a paragraph here.

Revised migration table

language blocked by
ruby nothing structural. Ships with the 876-row qualifier pin; #112's three host gates are closed.
php nothing measured.
python nothing measured.
rust the qualifier gap, for join_use's assembled brace-import prefixes (use std::sync::{atomic::{…}} → std::sync::atomic, which appears nowhere contiguously).
typescript / javascript the Visibility::Unknown third state above, cost unmeasured.
csharp is_extension — an unmade capability decision (#77) — plus the qualifier gap for emit_implicit_member_access's fabricated "this".

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

## Re-measured against a real port, 2026-09-05 — the qualifier-text gap is now the ONLY remaining product delta between a packaged language and a builtin one The previous comment said column parity was zero for all six and that the cause was host-side (#112), not the ABI. **#112 is now fixed**, so this issue's gap list can be stated against a clean background for the first time. `ruby_package_parity`, after the fix, over 156 `ruby-sinatra` files, 1260 symbols and 18,450 ref sites: | mechanism | rows | owner | |---|---:|---| | `QualifiedNameLangGate` | **0** (was 1260) | #112, closed | | `SameDirLangGate` | **0** (was 53) | #112, closed | | `TypePositionLangGate` | **0** (was 19) | #112, closed | | **`SelfQualifier` — the fact ABI has no qualifier TEXT** | **876** | **this issue** | | `EcosystemMarker` | 4 | the harness's shadow extension | Every symbol column is now identical row for row — the parity suite's symbol loop asserts `diff.is_empty()` and carries no pinned mechanism at all. **876 ref rows and one harness artifact are what is left.** So the framing changes: this is no longer "one of several ABI gaps beside several host gaps". The host gaps are closed, and the qualifier-text gap is the single thing standing between a packaged Ruby and a builtin one. It is also, per the earlier causal experiment (`ruby.rs` patched to `None` took 876 → 0 and the resolution delta 99 → 57), the one with a measured recall cost that **no manifest and no host change can close** — unlike #112's three, which turned out to be one gate. ### Gap status, restated | gap | status | |---|---| | `attr_start_line` | **REFUTED**, as recorded above. `TAG_SYMBOL_ATTR_SPAN` (0x8001) exists and `facts_to_extract` maps it, graded with three run mutations. | | `is_extension` | **Confirmed, and it is a #77 capability decision, not a missing slot.** The wire tag (0x8002) decodes; the host writes literal `false` because admitting the bit *is* the authorisation into the C# extension-method binding pool. | | **qualifier TEXT** | **Confirmed, and now the only live one.** | ### On the TypeScript `Visibility::Unknown` finding in the previous comment Re-checked, and it needs correcting in one direction and sharpening in the other. **The `unwrap_or(2)` is not in production and is not undocumented.** It is `crates/plugins/tests/abi_projection.rs` — a test harness — and its comment says the mapping is deliberate: `unknown` is absent from `code_index_abi::VISIBILITIES` on purpose, `DEFAULT_VISIBILITY` is `VisibilityId(2)` = `file`, and `registry.rs` states the reason at length: *"'not extracted, so treat as widely visible' is precisely the authority a third party may not assert."* `a_packaged_extractor_cannot_claim_unknown_visibility` grades it. Calling it a silent swallow was wrong. **The residue underneath it is real, and it is a fourth ABI gap in this issue's own sense.** `core::raw::Visibility`'s own note names the asymmetry: `Unknown` pools as `Exported` for a compiled plugin and defaults to `File` for a packaged one. Those are opposite ends of the visibility lattice. `typescript.rs:955` returns `Unknown` for a CommonJS file whose exports the plugin could not detect — and its comment says exactly why that must not become `File`: *"mis-marking those File would silently drop real cross-file resolutions."* So a packaged TypeScript would take precisely the loss that arm exists to avoid. Not a bug in the mapping — the mapping is the correct least-authority default — but a **capability shape the ABI cannot express**: "I looked and could not tell" is a third state, and the wire has only "exported" and the four it enumerates. **The recall cost is still unmeasured, and I did not measure it either.** Stated rather than implied. The measurement that settles it is one query on an indexed `js-express` or `ts-zod`: `SELECT COUNT(*) FROM symbols WHERE lang IN ('javascript','typescript') AND visibility = 'unknown'`, and then how many refs currently resolve to those rows. If it is near zero the gap is theoretical and TypeScript can port; if it is not, it is a blocker of the same kind as `is_extension` is for C#, and it should be split out rather than left as a paragraph here. ### Revised migration table | language | blocked by | |---|---| | ruby | nothing structural. Ships with the 876-row qualifier pin; #112's three host gates are closed. | | php | nothing measured. | | python | nothing measured. | | rust | the qualifier gap, for `join_use`'s assembled brace-import prefixes (`use std::sync::{atomic::{…}}` → `std::sync::atomic`, which appears nowhere contiguously). | | typescript / javascript | the `Visibility::Unknown` third state above, cost unmeasured. | | csharp | `is_extension` — an unmade capability decision (#77) — plus the qualifier gap for `emit_implicit_member_access`'s fabricated `"this"`. | 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

The Visibility::Unknown residue is MEASURED and it is not theoretical — split out as #166. The qualifier-TEXT gap is NOT attempted here, and the reason is a blocked gate

Two things, one closed and one deliberately refused.

1. The open measurement in the previous comment is now done

That comment said, of the Visibility::Unknown third state: "The recall cost is still unmeasured, and I did not measure it either. Stated rather than implied. … If it is near zero the gap is theoretical and TypeScript can port; if it is not, it is a blocker of the same kind as is_extension is for C#, and it should be split out."

It is not near zero. A packaged JavaScript loses 19.4% of ALL its resolutions.

Measured causally, not bounded: typescript.rs's CommonJS arm was changed to return Visibility::File — the exact mapping facts_to_extract applies to a packaged extractor — and both corpora were re-indexed from scratch with the same binary otherwise.

corpus shipped simulated packaged delta
js-express 4,153 resolved 3,348 −805 (−19.4%)
ts-zod 10,781 resolved 10,828 +47 (+0.4%)

The population underneath it: js-express is 1,860 of 1,917 symbols unknown (97.0%), with 833 refs resolving cross-file into them — which is what the 805 tracks. ts-zod is 4,387 of 9,667 (45.4%) with 130 cross-file.

Split out as #166, with the method so it can be re-run. The plugin edit was reverted and md5-verified; nothing is checked in.

That makes the revised migration table's typescript / javascript row concrete: javascript is blocked hard, and by the largest measured number in this issue's family.

2. The qualifier-TEXT gap — NOT attempted, and the precondition is named

This lane held the packaged-Ruby surface and #86's live gap was in scope. It was not built, and the reason is not effort.

The 876 rows are verified by exactly one gate, and that gate is RED for an unrelated reason. ruby_package_parity is failing at integration HEAD — bisected to 2f16e22's #134 tier-3 origin gate, filed as #167 with the mechanism (manifest_package_dirs is basename-keyed and the parity harness shadows Gemfile to Gemfile.rbx, so pkg_dir is '' on the whole package leg and the gate is vacuously true there). 17 unexplained ref deltas, in both directions.

An ABI change whose whole proof is "this gate's 876-row SelfQualifier mechanism goes to 0" cannot be landed while that gate cannot be read green. It would be a green that means nothing, which is the failure mode this issue's own history keeps finding.

Two further reasons, stated so the refusal is inspectable rather than just cautious:

  • it is a capability change, not only a wire one — a qualifier the source does not contain is authority of the same kind derived_names is, so it needs a fourth [capabilities] key through manifest → HostPolicy → Grants → activation digest, exactly as #103 shipped for derived names. That moves extraction_identity and re-extracts every claimed file on every project with the package enabled;
  • crates/indexer/src/index.rs's origin gate is being edited by another lane right now (#165). A resolution-moving change landing beside it makes both deltas uninspectable.

The sequencing that works: #167 first (it is a host gate, and it is in the same code #165 is already in), then the qualifier-text capability with the 876 → 0 delta read off a green parity suite, in one change with the guest rebuild and the package version bump — the same coupled-landing shape #102 needed.

#102's packaged half DID land in this lane (f3fceed on wip/rubypkg), with the full coupled-artifact set moved together and package version 0.4.0. That one was verifiable without the parity suite, because plugin check grades the guest against the builtin-generated expectations directly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

## The `Visibility::Unknown` residue is MEASURED and it is not theoretical — split out as #166. The qualifier-TEXT gap is NOT attempted here, and the reason is a blocked gate Two things, one closed and one deliberately refused. ### 1. The open measurement in the previous comment is now done That comment said, of the `Visibility::Unknown` third state: *"The recall cost is still unmeasured, and I did not measure it either. Stated rather than implied. … If it is near zero the gap is theoretical and TypeScript can port; if it is not, it is a blocker of the same kind as `is_extension` is for C#, and it should be split out."* It is not near zero. **A packaged JavaScript loses 19.4% of ALL its resolutions.** Measured causally, not bounded: `typescript.rs`'s CommonJS arm was changed to return `Visibility::File` — the exact mapping `facts_to_extract` applies to a packaged extractor — and both corpora were re-indexed from scratch with the same binary otherwise. | corpus | shipped | simulated packaged | delta | |---|---:|---:|---:| | `js-express` | 4,153 resolved | **3,348** | **−805 (−19.4%)** | | `ts-zod` | 10,781 resolved | 10,828 | +47 (+0.4%) | The population underneath it: `js-express` is **1,860 of 1,917** symbols `unknown` (97.0%), with 833 refs resolving cross-file into them — which is what the 805 tracks. `ts-zod` is 4,387 of 9,667 (45.4%) with 130 cross-file. Split out as **#166**, with the method so it can be re-run. The plugin edit was reverted and md5-verified; nothing is checked in. That makes the revised migration table's `typescript / javascript` row concrete: **javascript is blocked hard, and by the largest measured number in this issue's family.** ### 2. The qualifier-TEXT gap — NOT attempted, and the precondition is named This lane held the packaged-Ruby surface and #86's live gap was in scope. It was not built, and the reason is not effort. **The 876 rows are verified by exactly one gate, and that gate is RED for an unrelated reason.** `ruby_package_parity` is failing at `integration` HEAD — bisected to `2f16e22`'s #134 tier-3 origin gate, filed as **#167** with the mechanism (`manifest_package_dirs` is basename-keyed and the parity harness shadows `Gemfile` to `Gemfile.rbx`, so `pkg_dir` is `''` on the whole package leg and the gate is vacuously true there). 17 unexplained ref deltas, in both directions. An ABI change whose whole proof is "this gate's 876-row `SelfQualifier` mechanism goes to 0" cannot be landed while that gate cannot be read green. It would be a green that means nothing, which is the failure mode this issue's own history keeps finding. Two further reasons, stated so the refusal is inspectable rather than just cautious: * it is a **capability** change, not only a wire one — a qualifier the source does not contain is authority of the same kind `derived_names` is, so it needs a fourth `[capabilities]` key through manifest → `HostPolicy` → `Grants` → activation digest, exactly as #103 shipped for derived names. That moves `extraction_identity` and re-extracts every claimed file on every project with the package enabled; * `crates/indexer/src/index.rs`'s origin gate is being edited by another lane right now (#165). A resolution-moving change landing beside it makes both deltas uninspectable. **The sequencing that works:** #167 first (it is a host gate, and it is in the same code #165 is already in), then the qualifier-text capability with the 876 → 0 delta read off a green parity suite, in one change with the guest rebuild and the package version bump — the same coupled-landing shape #102 needed. ### Related landing #102's packaged half DID land in this lane (`f3fceed` on `wip/rubypkg`), with the full coupled-artifact set moved together and package version 0.4.0. That one was verifiable without the parity suite, because `plugin check` grades the guest against the builtin-generated expectations directly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

Triage 2026-09-06: LEFT OPEN — PARTIAL. Gaps 1 and 2 are closed (gap 2's premise is outright refuted), gap 3 is a deliberate unmade decision, gap 4 is the live one.

Comments supersede the body heavily here; graded as four gaps, per those.

gap state citation
1. derived-ref marker CLOSED Ref::derived_name — crates/abi/src/record.rs:151 (52 sites); grant path proven by ruby_package_e2e::a_rails_association_is_refused_for_want_of_the_derived_name_grant and …an_operator_can_grant_derived_names_and_the_rails_file_then_indexes
2. attr_start_line CLOSED — the body's premise is FALSE TAG_SYMBOL_ATTR_SPAN = 0x8001 at crates/abi/src/record.rs:17, and it is mapped: crates/indexer/src/packages.rs:4475 attr_start_line: s.attr_span.map(|b| pos.line_col(b.start as usize).line), plus withhold_foreign_attr_lines at :4484
3. is_extension ABI half closed; end-to-end OPEN BY DECISION tag 0x8002 at record.rs:18; host writes literal is_extension: false at packages.rs:4481, with the reason at :4462-4473
4. qualifier TEXT OPEN — the live one the wire carries a span, never a string — pinned in-tree at packages.rs:4869-4871

Gap 2 is worth calling out

The body says facts_to_extract hardcodes None. It does not — the mapping is at packages.rs:4475. That sentence should be struck; leaving it invites someone to "fix" a thing that works.

Gap 3 is a decision, not an omission

Wiring is_extension through would falsify pool_capability_registry's NeverDynamic("s.is_extension = 1") stance and admit a package to the C# extension pool with no grant. That is a #77 capability decision and the comment at packages.rs:4462-4473 says so. It should be taken deliberately or recorded as declined — not left reading as unfinished plumbing.

Gap 4 is where the work is

The 876-row SelfQualifier delta stands, and it is the only pinned delta left in ruby_package_parity — the three #112 populations went to zero today (see #112, closed). It was explicitly not attempted because it is blocked behind #167/#170: ruby_package_parity is that gap's only grader, and it has been red for an unrelated reason.

So gap 4 is blocked on someone else's lane, which is a fine reason to leave this open and a bad reason to leave it unstated.

One doc-drift defect found while verifying — worth fixing while here

crates/indexer/src/packages.rs:4478-4480 still says:

"C# EXTENSION METHODS ARE A BUILTIN CONCEPT. The wire has no bit for it"

That contradicts TAG_SYMBOL_IS_EXTENSION at crates/abi/src/record.rs:18, and contradicts the longer comment fifteen lines above it in the same expression, which correctly says "Its ABI half is closed too." One file, one expression, two opposite claims — and it sits exactly where a future reader would look to decide whether gap 3 is possible.

🤖 Triage lane, 2026-09-06, master 45cf6e4

## Triage 2026-09-06: LEFT OPEN — **PARTIAL.** Gaps 1 and 2 are closed (gap 2's premise is outright **refuted**), gap 3 is a deliberate unmade decision, gap 4 is the live one. Comments supersede the body heavily here; graded as four gaps, per those. | gap | state | citation | |---|---|---| | **1. derived-ref marker** | **CLOSED** | `Ref::derived_name` — `crates/abi/src/record.rs:151` (52 sites); grant path proven by `ruby_package_e2e::a_rails_association_is_refused_for_want_of_the_derived_name_grant` and `…an_operator_can_grant_derived_names_and_the_rails_file_then_indexes` | | **2. `attr_start_line`** | **CLOSED — the body's premise is FALSE** | `TAG_SYMBOL_ATTR_SPAN = 0x8001` at `crates/abi/src/record.rs:17`, and it is **mapped**: `crates/indexer/src/packages.rs:4475` `attr_start_line: s.attr_span.map(\|b\| pos.line_col(b.start as usize).line)`, plus `withhold_foreign_attr_lines` at `:4484` | | **3. `is_extension`** | **ABI half closed; end-to-end OPEN BY DECISION** | tag `0x8002` at `record.rs:18`; host writes literal `is_extension: false` at `packages.rs:4481`, with the reason at `:4462-4473` | | **4. qualifier TEXT** | **OPEN — the live one** | the wire carries a **span, never a string** — pinned in-tree at `packages.rs:4869-4871` | ### Gap 2 is worth calling out The body says `facts_to_extract` hardcodes `None`. **It does not** — the mapping is at `packages.rs:4475`. That sentence should be struck; leaving it invites someone to "fix" a thing that works. ### Gap 3 is a decision, not an omission Wiring `is_extension` through would falsify `pool_capability_registry`'s `NeverDynamic("s.is_extension = 1")` stance and admit a package to the C# extension pool **with no grant**. That is a #77 capability decision and the comment at `packages.rs:4462-4473` says so. It should be taken deliberately or recorded as declined — not left reading as unfinished plumbing. ### Gap 4 is where the work is The 876-row `SelfQualifier` delta stands, and it is the **only** pinned delta left in `ruby_package_parity` — the three #112 populations went to zero today (see #112, closed). It was explicitly **not attempted** because it is blocked behind #167/#170: `ruby_package_parity` is that gap's only grader, and it has been red for an unrelated reason. So gap 4 is blocked on someone else's lane, which is a fine reason to leave this open and a bad reason to leave it unstated. ### One doc-drift defect found while verifying — worth fixing while here `crates/indexer/src/packages.rs:4478-4480` still says: > *"C# EXTENSION METHODS ARE A BUILTIN CONCEPT. **The wire has no bit for it**"* That contradicts `TAG_SYMBOL_IS_EXTENSION` at `crates/abi/src/record.rs:18`, **and** contradicts the longer comment fifteen lines above it *in the same expression*, which correctly says *"Its ABI half is closed too."* One file, one expression, two opposite claims — and it sits exactly where a future reader would look to decide whether gap 3 is possible. 🤖 Triage lane, 2026-09-06, master `45cf6e4`
buildagent changed title from plugin ABI: three gaps that block migrating any language other than Ruby or PHP to plugin ABI: the qualifier-TEXT gap (gaps 1 and 2 CLOSED; gap 3 is a #77 capability decision, not an ABI gap) 2026-09-06 18:40:05 +02:00
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#86
No description provided.