plugin ABI: immutable package format and validated extraction-fact protocol #76
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#49 test oracle: frozen authoritative targets for builtin and external plugin languages
h-dv/code-index
#68 recall: emit field/member facts for Rust and PHP across builtin and package paths
h-dv/code-index
#75 epic: runtime plugin architecture — dynamic grammar, extraction and resolution packages
h-dv/code-index
Reference
h-dv/code-index#76
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Child of #75. This issue defines the contract between an immutable plugin package, the supervised runtime host, and the trusted indexer core.
It replaces the earlier proposal for “one node-kind/role table per language.” Declarative rules remain a supported extractor tier, but they are not the plugin architecture: complete language support may require a bounded executable extractor component.
Outcome
A third party can build a content-addressed package for a grammar and extractor code that did not exist when code-index was released. The core can validate the package without executing it, negotiate a versioned ABI with #79, and validate every emitted fact before it reaches the database.
Package layout
The canonical archive is deterministic and safe to unpack:
Requirements:
Manifest contract
The manifest declares, at minimum:
No package may request ambient filesystem, environment, clock, randomness, subprocess or network access. If a future ABI adds a capability, absence remains the default.
Claim precedence is explicit and deterministic. A tie between packages is a configuration error, never “first filesystem listing wins.” Reordering claims changes package activation identity and is handled by #78.
Host ABI
Use a versioned, length-delimited binary protocol shared by declarative and executable extractors. The package never links to internal Rust types or SQLite schema.
The request contains only bounded data:
The response contains extraction facts and diagnostics. It cannot contain database ids, resolved target ids, SQL, filesystem paths outside the logical file, or instructions to load another component.
The ABI must support cancellation by process termination. It must not require cleanup messages for correctness.
Extraction facts
The fact protocol covers:
Symbols
References
Imports
Diagnostics
Parent-side validator
Every response is hostile input. Before database insertion, the core validates:
One invalid fact rejects the component result for that file. Partial insertion is forbidden. The file receives a stable plugin diagnostic and the previous active generation remains visible under #78.
Validation code belongs in a dependency-light crate fuzzable without starting a daemon, tree-sitter or SQLite.
Declarative extractor tier
Declarative extraction supports bounded operations only:
No arbitrary regex with unbounded backtracking, loops, recursion, user code or host callbacks. Query compilation and match counts have hard budgets.
The format must express the fields that affect resolver safety: visibility, qualified-vs-qualifier, roles, parent ordering and source language. Defaults are least authority: file visibility, searchable-only symbols, unresolved refs and no bridge capability.
Executable extractor tier
extractor.wasm exists for semantics a data table cannot honestly model: scope state, rebinding, framework conventions, macro/token handling or language-specific normalization.
It uses the same fact ABI. It does not receive raw tree-sitter pointers or host addresses. The host exposes a bounded tree/event representation or runs grammar plus extractor within the isolated worker. ABI representation and serialization costs must be measured before freezing v1.
An extractor cannot mint new language ids, kinds or capabilities at runtime; all are manifest-declared and install-time validated.
Language profile schema
The package format defines data consumed by #77:
Hardcoded language allowlists must be inventory-tested and migrated to either a core invariant or a profile field. A new dynamic language with an omitted required semantic must fail activation loudly, not silently lose a resolver tier.
Compatibility
CLI surface owned here
These commands do not execute grammar or extractor code. Runtime checking belongs to #80 through the supervised host.
Tests
Acceptance
feat: data-defined extractor layer — the 85% a runtime grammar does not solveto plugin ABI: immutable package format and validated extraction-fact protocolAudit: 4 of 6 criteria met, and this issue is now the single formal blocker for #77.
Audited against the tree at v0.26.1 plus ~95 uncommitted paths. Everything below was read, not grepped for.
crates/cli/src/plugin.rs:140/147/149(Inspect/Validate/Digest); XAML and Ruby both install by pinned digestcrates/indexer/src/packages.rs:1537refusesTier::Declarativeoutrightcrates/abi/src/record.rs:575 validate(), all-or-nothing;Grants::defaultdenies;crates/abi/tests/abi_fuzz.rsexhaustive corruptionextraction_identity.rs::every_manifest_field_moves_exactly_the_digests_it_should,claim_order_is_identity;digest_stability.rs::every_byte_of_every_entry_moves_the_digestcrates/abi/src/lib.rs:161 dependency_gate::this_crate_takes_no_dependenciestests/packages/ruby/+ parity on all three axes — all uncommittedC2 — unmet by construction, and the tree is honest about it
This is easy to miss precisely because the disclosure is good.
coverage::DECLARATIVE_TIER_IMPLEMENTED = false(crates/indexer/src/coverage.rs:127) drives arules_coverage_absentdisclosure, andreason_code_registry.rs::the_declarative_tier_constant_matches_the_refusalbinds the constant to the refusal in both directions, with its mutation recorded. Verified live:index_coverage("tests/packages/xaml/fixtures/MainWindow.xaml")returnscoverage_reasons: ["rules_coverage_absent"].Exemplary honesty; still an unmet criterion. #75's governing text is explicit — "Both tiers emit the same validated fact protocol."
So the decision this issue needs is a product one, not an implementation one: build the declarative tier, or remove
Tier::Declarativefrom the manifest and retirerules_coverage_absent. Carrying a manifest field the host refuses is the more expensive of the two states.C6 — the part that changed today
tests/packages/ruby/now holds a real 2 MBtree-sitter-rubygrammar and a 29 KB Rust→wasm guest extractor built fromcrates/guest/ruby/, with parity on all three axes #84 specifies:crates/package/tests/ruby_claim_parity.rscrates/plugins/tests/ruby_builtin_expectations.rscrates/indexer/tests/ruby_package_parity.rsAxis C is genuinely strong: producer identity read from
file_contributionsrather than an env var, four absolute floors from the builtin leg, six pool capabilities asserted granted, and five pinned deltas each carrying a predicate checked on the row rather than a column exclusion. It also caught a real defect on its first run — the shipped package declaredresolver = []and resolved zero of 2853 refs while symbols, refs and imports were byte-identical to the builtin.Two things stand between that and C6 being met on master:
git log --allhas none of it.corpus::repo_pathfails without a corpus, the suite records a skip, and the run passes having compared nothing. Wiring is in flight with the lane that owns.forgejo/(--test ruby_package_parity --test ruby_package_costinto thecorpusjob).Until both land, C6 is met by artefacts that exist only on one machine and a gate that cannot fail. That is not a criterion I am willing to tick.
Also unmet, from the manifest contract
Bounded path globs do not exist.
claim::KeyisExt | Name | Suffixonly — this issue's "ordered path claims: extensions, exact basenames and bounded globs" is two of three. There is also a fixture row namedbounded_glob_claimthat does not test a glob; it should be renamed rather than left to imply coverage.Status
KEEP OPEN. #77 depends on this issue and Forgejo correctly refused to close #77 while it stands. Realistic path: commit the Ruby migration + land the CI wiring → C6 met; then C2 is the one remaining decision, and it is a product call rather than a lane of work.
C2 is retired by decision, not left unmet. Amending this issue by comment rather than editing it away.
Following this repo's own precedent (#84's correction comment): the way the text was wrong is itself useful, so it stays.
Three parts of this issue are now obsolete rather than unsatisfied:
rules/*.toml optionalline in the package layout. (Note it never existed in code:check_entriesimplicitly allows onlyqueries/*.scmviamanifest::is_query, and arules/*.tomlfile was legal solely because a claim'scomponentcould name it —componenthas no extension rule. The line lived only in this issue's text.)The decision and its reasoning are recorded on the epic; in short, the tier's rationale was "author a language without writing code", and
crates/guestplus the #84 Ruby port (29 KB, ~1.5× grammar cost, all three parity axes) made the executable path the demonstrated one.The retirement is NOT what this issue's audit comment literally proposed, and there is a measurement for why
The audit said "remove
Tier::Declarativefrom the manifest". That is not safely executable, and M3 is the number:ClaimDeclisdeny_unknown_fieldswith no default fortier, so deleting the field refuses every already-published manifest — including the signedde.h-dv.xaml 0.1.0release asset. That is a worse trap than the one retiring the tier removes. Deleting only the variant is nearly as bad, and M3 measures it.There is a second, harder reason: the tier is one byte of
extraction_identity, which keys an activation generation. Dropping the byte — or renumberingExecutablefrom1to0now that0is free — moves the recorded identity of every package in the field.So the shape is: retired as a feature, retained as identity, refused by name. The
tierfield stays parsed and digested;declarativeis a value the manifest layer still represents and every door that reads a package for use refuses.Where the rule could not go, and why that matters
Not in
Manifest::validate—validateruns insideparse, so refusing there makes the canonicalextraction_identityfixture unparseable and itsClaimTierrow unconstructible. The byte that must never move would end up graded by strictly less than it is now. A retirement that costs the compatibility claim its own gate is not worth having, so the rule sits at acceptance, not at schema.Two doors, not three — and the third was measured, not assumed
Manifest::check_supported_tiersis called fromapproval::StoredPackage::parse(install/add/enable/trust, theplugin_addMCP tool, and every later read of stored bytes —Store::getre-parses,PackageSet::activatecalls it per approved digest) and fromcli::plugin::parse_package(pack/inspect/validate/digest).That second door is the one the retirement adds, and it was the actual trap:
plugin validateused to printokfor a manifest the host would later refuse.packages::load_one's inline refusal is deleted as unreachable — measured, not assumed. The upgrade-path test was written expecting the staging refusal and went RED against the store's, on a build that still had both: the refusal arrives asarchive_refusedfromStore::get, never asmanifest.invalid_valuefromload_one. A legacy declarative package — every release through v0.26.1 admitted one — is still refused on upgrade, with the tier's own witness rather than a downstreamextractor.malformed.Compatibility proved from real pre-change digests
tests/packages/xaml.digestandtests/packages/ruby.digestare checked-in, pre-change, and untouched. Boththe_shipped_package_is_the_artifact_that_was_recordedgates pass, and both packages still install and index end to end.M1 — delete the tier byte from
extraction_identity. RED, and this is what earns the claim:The retired reason code
rules_coverage_absentmoves fromCOVERAGE_REASONSinto a newcoverage::RETIREDregister — deliberately the mirror ofOWED, a generic mechanism for any future retirement rather than a one-off.DECLARATIVE_TIER_IMPLEMENTEDis gone.A finding that changes what "both wire directions" even means here:
rules_coverage_absentnever crossed the daemon RPC.coverage::qualifyruns in the MCP process from a compiled-in constant, andcoverage_reasons_forreturnsVec<&'static str>, so a peer string structurally cannot enter the vocabulary. The classic two-sided skew does not exist for this code — reported rather than papered over with a test for a path that isn't there.The direction that does cross the wire is
signature_refusals, and it is driven from the register rather than a literal:M11b — a lenient
observefolds an unknown code onto a real bit. RED:Consequence worth recording:
coverage_reasonscan now be[]on a healthy project. The "empty is a measurement" sentence has described an unreachable state since the code shipped. It is reachable now.What went wrong in the doing, recorded
"Tier::Declarative => {", whichTier::Declarative => {}still contains — so emptying the arm walked straight past it. Re-aimed at the witness text. A source scan matching the shape of a refusal rather than its content grades nothing.s.index("\n];", start)over a block ending)];swallowed the next constant and deletedcoverage::OWEDoutright. Caught by the registry's population floor, restored fromgit show HEAD:, verified byte-identical. The same wrong anchor was then found already in the tree and both were replaced with a bracket-depth scan.owed_pairssplit on"\n (", whichrustfmtdoes not produce for a one-entry slice — soRETIREDparsed EMPTY on its firstcargo fmt, and only the floor caught it. A parser whose answer depends on how a formatter wrapped the source will be wrong again.Payload measured, not assumed:
coverage_semantics276 → 243 tokens; the ten constant sites 1,460 → 1,427 against a 1,500 ceiling, headroom 40 → 73.Remaining on this issue
C1, C3, C4, C5 met. C6 is met in substance — the Ruby migration exists with all three parity axes and axis C is now wired into the
corpusjob — but the artefacts are still uncommitted, so it closes on the commit, not before. Also still open from the manifest contract: bounded path globs do not exist (claim::KeyisExt | Name | Suffix), and the fixture row namedbounded_glob_claimtests no glob and should be renamed.Gates: fmt, clippy
-D warnings, rustdoc,cargo test --workspace261 suites / 0 failed, daemon leg 45 / 0,precision_gate7/7,baseline.jsonmd5 unmoved.archive_refusedreaches nocoverage_reasonscode, so an agent's answer is qualified by nothing #124C6 is met on master, and CI is green. Closing.
The two things standing between C6 and closure were named earlier in this issue: the Ruby migration was uncommitted, and its axis C was in no CI job. Both are resolved.
1aa6514(the migration, guest SDK, grant path, declarative retirement),bff9f15,a5f91c2,01a478b.ruby_package_parityandruby_package_costrun in thecorpusjob underCOSI_CORPUS_REQUIRE=1, alongsideupgrade_equivalence— which the same work found had never graded a repo.cargo test,cargo test (daemon transport)andOSS corpus (tier 1). Windows run 583 green.What C6 actually rests on now
A complete builtin language ships as an external package, proven on three axes with the numbers in #84: 1254/1254 symbols identical on 9 of 10 columns, 18450/18450 ref sites, 231/231 imports, five pinned deltas each carrying a predicate checked on the row rather than a column exclusion.
And the gate earned its keep on first contact: the shipped package declared
resolver = []and resolved 0 of 2853 refs while symbols, refs and imports were byte-identical to the builtin. Extraction parity alone would have called that a perfect port.One thing that was true when this issue was last updated and is not true now
The Ruby package's artifact was reproducible from exactly one directory on earth (#132) — a shipped, operator-installed, digest-pinned component whose recorded "reproducible in this repository" claim was false everywhere else. That is fixed, and the second cause behind it is worth recording here because it bears directly on this issue's package-format contract:
--strip-name-sectionwas necessary and not sufficient. The CI image exportsCARGO_INCREMENTAL=1, and incremental codegen is path-dependent past the name section, so no strip could rescue it.incremental = falsein[profile.release]does not override the env var — measured. Both guests now build byte-identically in the container and on the host, and the property is guarded generically:the_rustc_built_guests_carry_no_name_sectionwalks every checked-in.wasmand refuses any cargo-produced one still carrying a name section, discriminating on the decodedproducerssection rather than a substring.That matters for this issue because any guest built from
crates/guestinherits the same exposure, so a third-party author following our own SDK would ship an irreproducible artifact._prdoc/guides/80-package-authoring.md§1 now carries both requirements with their reasons.Remaining, and split out rather than swept
Tier::Declarativewas not safely executable are in the comment above.claim::KeyisExt | Name | Suffix, so this issue's "extensions, exact basenames and bounded globs" is two of three. The fixture row namedbounded_glob_claimtests no glob and should be renamed. That is a real, small residual and it should be its own issue rather than a reason to hold this one open — it blocks no acceptance criterion as amended.C1, C3, C4, C5 met with citations above; C2 retired; C6 met and green on master.
claim::Keyhas no bounded path globs, and a fixture row namedbounded_glob_claimtests no glob #135