plugin ABI: immutable package format and validated extraction-fact protocol #76

Closed
opened 2026-08-26 12:31:25 +02:00 by buildagent · 3 comments
Member

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:

plugin.toml
grammar.wasm
queries/*.scm                 optional
rules/*.toml                  optional
extractor.wasm                optional
fixtures/**                   required for publish/conformance
LICENSE
checksums                     derived, not trusted input

Requirements:

  • normalized relative UTF-8 paths only; no absolute paths, dot-dot, symlinks or duplicate case-folded names;
  • deterministic file order, timestamps and compression settings;
  • maximum archive, expanded, per-file and file-count bounds;
  • content digest computed over a canonical length-prefixed encoding, not archive container bytes;
  • package id is reverse-domain/namespaced and language ids are scoped beneath it;
  • semantic version is metadata; the digest is identity;
  • unknown required fields fail closed, while explicitly optional extension blocks can be ignored by declared version rules.

Manifest contract

The manifest declares, at minimum:

  • package-format version;
  • required host ABI range;
  • package id/version/digest algorithm;
  • grammar artifact, exported language name and supported tree-sitter ABI;
  • one or more namespaced language ids;
  • ordered path claims: extensions, exact basenames and bounded globs;
  • extractor components and which claims invoke them;
  • language resolution profiles;
  • requested resolver and bridge capabilities;
  • resource ceilings at or below host maximums;
  • fixtures and expected fact projections;
  • package dependencies, if supported, pinned by digest.

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:

  • ABI version and request id;
  • package digest, component id and language id;
  • project-relative logical path;
  • file bytes already accepted by the parent eligibility and size gates;
  • selected claim id;
  • optional outer-region/source-map information for embedded extraction;
  • deterministic configuration values explicitly declared by the ABI.

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

  • local fact id, unique within the response;
  • name, kind, signature and documentation under byte limits;
  • language id and component id;
  • byte and line/column spans;
  • visibility, defaulting to file-local;
  • optional parent local fact id, required to precede the child;
  • qualified-name evidence as structured components, not an unchecked prejoined string;
  • bounded roles/capability class from a closed ABI registry.

References

  • name and ref kind;
  • language id and component id;
  • source span;
  • qualified flag and structured qualifier evidence;
  • role bits such as type-position/data-member-only;
  • binding/receiver evidence where available;
  • no target symbol id and no “resolved” assertion.

Imports

  • source language/component;
  • structured module segments;
  • alias/binding information;
  • source span and import class.

Diagnostics

  • stable reason code;
  • bounded human detail;
  • component and source span when applicable;
  • severity limited to the host-defined taxonomy.

Parent-side validator

Every response is hostile input. Before database insertion, the core validates:

  • response byte and row budgets;
  • exact request/package/component identity;
  • UTF-8 and string limits;
  • spans within the supplied file or declared source-map region;
  • line/column consistency recomputed from bytes rather than trusted;
  • parent-before-child ordering, no cycles and valid local ids;
  • known kinds, ref kinds, roles and visibility;
  • language/component ownership;
  • capability use is a subset of the installed and project-approved grant;
  • declarative and executable extractors satisfy identical rules.

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:

  • compiled tree queries/captures;
  • field/ancestor/descendant relations with depth limits;
  • literal and identifier selection;
  • fixed normalization operations from a closed registry;
  • container construction;
  • qualifier/receiver capture;
  • region extraction with a validated source map;
  • emission of facts defined above.

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:

  • identifier and case normalization;
  • qualified-name representation/separators;
  • type/member/container kind classes;
  • visibility semantics;
  • import and relative-module rules;
  • type-position and data-member roles;
  • allowed resolver pool/tier capabilities;
  • cross-language bridge requests.

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

  • Host and package negotiate an integer ABI plus feature bits.
  • New optional response fields are length-delimited and safely skippable.
  • Semantic changes require a new ABI or engine version included in #78’s generation identity.
  • The core supports at least the current and immediately previous stable ABI during a documented migration window.
  • Reason-code strings and fact enums are wire contracts from first release.
  • An old daemon that cannot report plugin state must produce an explicit unavailable disclosure through #80.

CLI surface owned here

  • code-index plugin inspect
  • code-index plugin digest
  • code-index plugin validate

These commands do not execute grammar or extractor code. Runtime checking belongs to #80 through the supervised host.

Tests

  • canonical archive reproducibility;
  • archive traversal/symlink/case-collision bombs;
  • unknown required/optional fields;
  • every malformed fact shape;
  • oversized strings, rows, trees and diagnostics;
  • span/source-map forgery;
  • parent cycles and forward parents;
  • capability escalation;
  • package/component/request identity mismatch;
  • ABI previous/current/next negotiation;
  • mutation gate ensuring every manifest field that changes interpretation changes the digest;
  • fuzz targets for archive parsing, manifest parsing, ABI decode and fact validation.

Acceptance

  1. A package unknown to the compiled binary validates without code changes.
  2. Both a declarative extractor and executable extractor emit the same fact protocol.
  3. No malformed or over-capability response can reach database insertion.
  4. Package identity changes for every grammar, extractor, rule, profile, claim-order or engine-input change.
  5. The contract contains everything #79, #77 and #78 require without importing internal indexer structs.
  6. One complete existing plugin’s fixture projection can be represented through this ABI, proving it is not markup-only.
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: plugin.toml grammar.wasm queries/*.scm optional rules/*.toml optional extractor.wasm optional fixtures/** required for publish/conformance LICENSE checksums derived, not trusted input Requirements: - normalized relative UTF-8 paths only; no absolute paths, dot-dot, symlinks or duplicate case-folded names; - deterministic file order, timestamps and compression settings; - maximum archive, expanded, per-file and file-count bounds; - content digest computed over a canonical length-prefixed encoding, not archive container bytes; - package id is reverse-domain/namespaced and language ids are scoped beneath it; - semantic version is metadata; the digest is identity; - unknown required fields fail closed, while explicitly optional extension blocks can be ignored by declared version rules. ## Manifest contract The manifest declares, at minimum: - package-format version; - required host ABI range; - package id/version/digest algorithm; - grammar artifact, exported language name and supported tree-sitter ABI; - one or more namespaced language ids; - ordered path claims: extensions, exact basenames and bounded globs; - extractor components and which claims invoke them; - language resolution profiles; - requested resolver and bridge capabilities; - resource ceilings at or below host maximums; - fixtures and expected fact projections; - package dependencies, if supported, pinned by digest. 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: - ABI version and request id; - package digest, component id and language id; - project-relative logical path; - file bytes already accepted by the parent eligibility and size gates; - selected claim id; - optional outer-region/source-map information for embedded extraction; - deterministic configuration values explicitly declared by the ABI. 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 - local fact id, unique within the response; - name, kind, signature and documentation under byte limits; - language id and component id; - byte and line/column spans; - visibility, defaulting to file-local; - optional parent local fact id, required to precede the child; - qualified-name evidence as structured components, not an unchecked prejoined string; - bounded roles/capability class from a closed ABI registry. ### References - name and ref kind; - language id and component id; - source span; - qualified flag and structured qualifier evidence; - role bits such as type-position/data-member-only; - binding/receiver evidence where available; - no target symbol id and no “resolved” assertion. ### Imports - source language/component; - structured module segments; - alias/binding information; - source span and import class. ### Diagnostics - stable reason code; - bounded human detail; - component and source span when applicable; - severity limited to the host-defined taxonomy. ## Parent-side validator Every response is hostile input. Before database insertion, the core validates: - response byte and row budgets; - exact request/package/component identity; - UTF-8 and string limits; - spans within the supplied file or declared source-map region; - line/column consistency recomputed from bytes rather than trusted; - parent-before-child ordering, no cycles and valid local ids; - known kinds, ref kinds, roles and visibility; - language/component ownership; - capability use is a subset of the installed and project-approved grant; - declarative and executable extractors satisfy identical rules. 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: - compiled tree queries/captures; - field/ancestor/descendant relations with depth limits; - literal and identifier selection; - fixed normalization operations from a closed registry; - container construction; - qualifier/receiver capture; - region extraction with a validated source map; - emission of facts defined above. 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: - identifier and case normalization; - qualified-name representation/separators; - type/member/container kind classes; - visibility semantics; - import and relative-module rules; - type-position and data-member roles; - allowed resolver pool/tier capabilities; - cross-language bridge requests. 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 - Host and package negotiate an integer ABI plus feature bits. - New optional response fields are length-delimited and safely skippable. - Semantic changes require a new ABI or engine version included in #78’s generation identity. - The core supports at least the current and immediately previous stable ABI during a documented migration window. - Reason-code strings and fact enums are wire contracts from first release. - An old daemon that cannot report plugin state must produce an explicit unavailable disclosure through #80. ## CLI surface owned here - code-index plugin inspect <archive> - code-index plugin digest <archive> - code-index plugin validate <archive> These commands do not execute grammar or extractor code. Runtime checking belongs to #80 through the supervised host. ## Tests - canonical archive reproducibility; - archive traversal/symlink/case-collision bombs; - unknown required/optional fields; - every malformed fact shape; - oversized strings, rows, trees and diagnostics; - span/source-map forgery; - parent cycles and forward parents; - capability escalation; - package/component/request identity mismatch; - ABI previous/current/next negotiation; - mutation gate ensuring every manifest field that changes interpretation changes the digest; - fuzz targets for archive parsing, manifest parsing, ABI decode and fact validation. ## Acceptance 1. A package unknown to the compiled binary validates without code changes. 2. Both a declarative extractor and executable extractor emit the same fact protocol. 3. No malformed or over-capability response can reach database insertion. 4. Package identity changes for every grammar, extractor, rule, profile, claim-order or engine-input change. 5. The contract contains everything #79, #77 and #78 require without importing internal indexer structs. 6. One complete existing plugin’s fixture projection can be represented through this ABI, proving it is not markup-only.
buildagent changed title from feat: data-defined extractor layer — the 85% a runtime grammar does not solve to plugin ABI: immutable package format and validated extraction-fact protocol 2026-08-26 13:30:58 +02:00
Author
Member

Audit: 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.

# criterion verdict evidence
1 an unknown package validates without code changes MET crates/cli/src/plugin.rs:140/147/149 (Inspect/Validate/Digest); XAML and Ruby both install by pinned digest
2 both a declarative and an executable extractor emit the same fact protocol NOT MET crates/indexer/src/packages.rs:1537 refuses Tier::Declarative outright
3 no malformed or over-capability response reaches DB insertion MET crates/abi/src/record.rs:575 validate(), all-or-nothing; Grants::default denies; crates/abi/tests/abi_fuzz.rs exhaustive corruption
4 identity changes for every interpretation input MET extraction_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_digest
5 contract complete without importing indexer structs MET crates/abi/src/lib.rs:161 dependency_gate::this_crate_takes_no_dependencies
6 one complete existing plugin, proving not markup-only MET IN THE WORKING TREE ONLY tests/packages/ruby/ + parity on all three axes — all uncommitted

C2 — 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 a rules_coverage_absent disclosure, and reason_code_registry.rs::the_declarative_tier_constant_matches_the_refusal binds the constant to the refusal in both directions, with its mutation recorded. Verified live: index_coverage("tests/packages/xaml/fixtures/MainWindow.xaml") returns coverage_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::Declarative from the manifest and retire rules_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 MB tree-sitter-ruby grammar and a 29 KB Rust→wasm guest extractor built from crates/guest/ruby/, with parity on all three axes #84 specifies:

  • A crates/package/tests/ruby_claim_parity.rs
  • B crates/plugins/tests/ruby_builtin_expectations.rs
  • C crates/indexer/tests/ruby_package_parity.rs

Axis C is genuinely strong: producer identity read from file_contributions rather 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 declared resolver = [] 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:

  1. It is uncommitted. git log --all has none of it.
  2. Axis C is in no CI job, so it currently skips green — corpus::repo_path fails 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_cost into the corpus job).

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::Key is Ext | Name | Suffix only — this issue's "ordered path claims: extensions, exact basenames and bounded globs" is two of three. There is also a fixture row named bounded_glob_claim that 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.

## Audit: **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. | # | criterion | verdict | evidence | |---|---|---|---| | 1 | an unknown package validates without code changes | **MET** | `crates/cli/src/plugin.rs:140/147/149` (`Inspect`/`Validate`/`Digest`); XAML **and** Ruby both install by pinned digest | | 2 | **both a declarative and an executable extractor emit the same fact protocol** | **NOT MET** | `crates/indexer/src/packages.rs:1537` refuses `Tier::Declarative` outright | | 3 | no malformed or over-capability response reaches DB insertion | **MET** | `crates/abi/src/record.rs:575 validate()`, all-or-nothing; `Grants::default` denies; `crates/abi/tests/abi_fuzz.rs` exhaustive corruption | | 4 | identity changes for every interpretation input | **MET** | `extraction_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_digest` | | 5 | contract complete without importing indexer structs | **MET** | `crates/abi/src/lib.rs:161 dependency_gate::this_crate_takes_no_dependencies` | | 6 | **one complete existing plugin, proving not markup-only** | **MET IN THE WORKING TREE ONLY** | `tests/packages/ruby/` + parity on all three axes — all uncommitted | ### C2 — 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 a `rules_coverage_absent` disclosure, and `reason_code_registry.rs::the_declarative_tier_constant_matches_the_refusal` binds the constant to the refusal **in both directions**, with its mutation recorded. Verified live: `index_coverage("tests/packages/xaml/fixtures/MainWindow.xaml")` returns `coverage_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::Declarative` from the manifest and retire `rules_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 MB `tree-sitter-ruby` grammar and a 29 KB Rust→wasm guest extractor built from `crates/guest/ruby/`, with parity on all three axes #84 specifies: - **A** `crates/package/tests/ruby_claim_parity.rs` - **B** `crates/plugins/tests/ruby_builtin_expectations.rs` - **C** `crates/indexer/tests/ruby_package_parity.rs` Axis C is genuinely strong: producer identity read from `file_contributions` rather 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 declared `resolver = []` 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:** 1. **It is uncommitted.** `git log --all` has none of it. 2. **Axis C is in no CI job**, so it currently skips green — `corpus::repo_path` fails 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_cost` into the `corpus` job). 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::Key` is `Ext | Name | Suffix` only — this issue's *"ordered path claims: extensions, exact basenames and bounded globs"* is two of three. There is also a fixture row named `bounded_glob_claim` that 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.
Author
Member

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:

  1. Acceptance criterion 2 — "Both a declarative extractor and executable extractor emit the same fact protocol." There is one tier.
  2. The "Declarative extractor tier" section in full.
  3. The rules/*.toml optional line in the package layout. (Note it never existed in code: check_entries implicitly allows only queries/*.scm via manifest::is_query, and a rules/*.toml file was legal solely because a claim's component could name it — component has 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/guest plus 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::Declarative from the manifest". That is not safely executable, and M3 is the number:

a declarative manifest must still PARSE:
  Refusal { reason: ManifestUnknownField, witness: Some("declarative") }

ClaimDecl is deny_unknown_fields with no default for tier, so deleting the field refuses every already-published manifest — including the signed de.h-dv.xaml 0.1.0 release 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 renumbering Executable from 1 to 0 now that 0 is 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 tier field stays parsed and digested; declarative is 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 — validate runs inside parse, so refusing there makes the canonical extraction_identity fixture unparseable and its ClaimTier row 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_tiers is called from approval::StoredPackage::parse (install/add/enable/trust, the plugin_add MCP tool, and every later read of stored bytes — Store::get re-parses, PackageSet::activate calls it per approved digest) and from cli::plugin::parse_package (pack/inspect/validate/digest).

That second door is the one the retirement adds, and it was the actual trap: plugin validate used to print ok for 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 as archive_refused from Store::get, never as manifest.invalid_value from load_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 downstream extractor.malformed.

Compatibility proved from real pre-change digests

tests/packages/xaml.digest and tests/packages/ruby.digest are checked-in, pre-change, and untouched. Both the_shipped_package_is_the_artifact_that_was_recorded gates 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:

extraction_identity MOVED.
  recorded  sha256:1d4ecb7a…   packed  sha256:ad4eb4a8…
ruby:  left: "sha256:c77a0d41…"  right: "sha256:645668eb…"

The retired reason code

rules_coverage_absent moves from COVERAGE_REASONS into a new coverage::RETIRED register — deliberately the mirror of OWED, a generic mechanism for any future retirement rather than a one-off. DECLARATIVE_TIER_IMPLEMENTED is gone.

A finding that changes what "both wire directions" even means here: rules_coverage_absent never crossed the daemon RPC. coverage::qualify runs in the MCP process from a compiled-in constant, and coverage_reasons_for returns Vec<&'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 observe folds an unknown code onto a real bit. RED:

the RETIRED code `rules_coverage_absent` re-entered the vocabulary through the signature channel
  left: ["signature_untrusted", "signature_invalid"]   right: ["signature_untrusted"]

Consequence worth recording: coverage_reasons can 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

  • A survivor shipped and then killed. The first gate grepped for "Tier::Declarative => {", which Tier::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.
  • A mutation-safety incident. A span edit anchored on s.index("\n];", start) over a block ending )]; swallowed the next constant and deleted coverage::OWED outright. Caught by the registry's population floor, restored from git 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.
  • A pre-existing fragility that fell out of it: owed_pairs split on "\n (", which rustfmt does not produce for a one-entry slice — so RETIRED parsed EMPTY on its first cargo 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_semantics 276 → 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 corpus job — 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::Key is Ext | Name | Suffix), and the fixture row named bounded_glob_claim tests no glob and should be renamed.

Gates: fmt, clippy -D warnings, rustdoc, cargo test --workspace 261 suites / 0 failed, daemon leg 45 / 0, precision_gate 7/7, baseline.json md5 unmoved.

## 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:** 1. **Acceptance criterion 2** — *"Both a declarative extractor and executable extractor emit the same fact protocol."* There is one tier. 2. The **"Declarative extractor tier"** section in full. 3. The `rules/*.toml optional` line in the package layout. (Note it never existed in code: `check_entries` implicitly allows only `queries/*.scm` via `manifest::is_query`, and a `rules/*.toml` file was legal solely because a claim's `component` could name it — `component` has 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/guest` plus 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::Declarative` from the manifest". **That is not safely executable**, and M3 is the number: ``` a declarative manifest must still PARSE: Refusal { reason: ManifestUnknownField, witness: Some("declarative") } ``` `ClaimDecl` is `deny_unknown_fields` with no default for `tier`, so deleting the field refuses **every already-published manifest** — including the signed `de.h-dv.xaml 0.1.0` release 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 renumbering `Executable` from `1` to `0` now that `0` is 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 `tier` field stays parsed and digested; `declarative` is 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` — `validate` runs inside `parse`, so refusing there makes the canonical `extraction_identity` fixture unparseable and its `ClaimTier` row 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_tiers` is called from `approval::StoredPackage::parse` (install/add/enable/trust, the `plugin_add` MCP tool, and **every later read of stored bytes** — `Store::get` re-parses, `PackageSet::activate` calls it per approved digest) and from `cli::plugin::parse_package` (pack/inspect/validate/digest). That second door is the one the retirement **adds**, and it was the actual trap: **`plugin validate` used to print `ok` for 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 as `archive_refused` from `Store::get`, never as `manifest.invalid_value` from `load_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 downstream `extractor.malformed`. ## Compatibility proved from real pre-change digests `tests/packages/xaml.digest` and `tests/packages/ruby.digest` are checked-in, pre-change, and untouched. Both `the_shipped_package_is_the_artifact_that_was_recorded` gates 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:** ``` extraction_identity MOVED. recorded sha256:1d4ecb7a… packed sha256:ad4eb4a8… ruby: left: "sha256:c77a0d41…" right: "sha256:645668eb…" ``` ## The retired reason code `rules_coverage_absent` moves from `COVERAGE_REASONS` into a new **`coverage::RETIRED`** register — deliberately the mirror of `OWED`, a generic mechanism for any future retirement rather than a one-off. `DECLARATIVE_TIER_IMPLEMENTED` is gone. **A finding that changes what "both wire directions" even means here:** `rules_coverage_absent` **never crossed the daemon RPC.** `coverage::qualify` runs in the MCP process from a compiled-in constant, and `coverage_reasons_for` returns `Vec<&'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 `observe` folds an unknown code onto a real bit. RED:** ``` the RETIRED code `rules_coverage_absent` re-entered the vocabulary through the signature channel left: ["signature_untrusted", "signature_invalid"] right: ["signature_untrusted"] ``` Consequence worth recording: **`coverage_reasons` can 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 - **A survivor shipped and then killed.** The first gate grepped for `"Tier::Declarative => {"`, which `Tier::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.** - **A mutation-safety incident.** A span edit anchored on `s.index("\n];", start)` over a block ending `)];` swallowed the next constant and **deleted `coverage::OWED` outright**. Caught by the registry's population floor, restored from `git 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. - **A pre-existing fragility that fell out of it:** `owed_pairs` split on `"\n ("`, which `rustfmt` does not produce for a **one-entry** slice — so `RETIRED` parsed EMPTY on its first `cargo 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_semantics` 276 → **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 `corpus` job — 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::Key` is `Ext | Name | Suffix`), and the fixture row named `bounded_glob_claim` tests no glob and should be renamed. Gates: fmt, clippy `-D warnings`, rustdoc, `cargo test --workspace` **261 suites / 0 failed**, daemon leg **45 / 0**, `precision_gate` 7/7, `baseline.json` md5 unmoved.
Author
Member

C6 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.

  • Committed: 1aa6514 (the migration, guest SDK, grant path, declarative retirement), bff9f15, a5f91c2, 01a478b.
  • Wired: ruby_package_parity and ruby_package_cost run in the corpus job under COSI_CORPUS_REQUIRE=1, alongside upgrade_equivalence — which the same work found had never graded a repo.
  • Green: run 584, 10 jobs success / 3 skipped (nightly-only), including cargo test, cargo test (daemon transport) and OSS 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-section was necessary and not sufficient. The CI image exports CARGO_INCREMENTAL=1, and incremental codegen is path-dependent past the name section, so no strip could rescue it. incremental = false in [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_section walks every checked-in .wasm and refuses any cargo-produced one still carrying a name section, discriminating on the decoded producers section rather than a substring.

That matters for this issue because any guest built from crates/guest inherits 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

  • C2 (declarative tier) is retired by decision, not left unmet — the amendment and the measurement for why deleting Tier::Declarative was not safely executable are in the comment above.
  • Bounded path globs do not exist. claim::Key is Ext | Name | Suffix, so this issue's "extensions, exact basenames and bounded globs" is two of three. The fixture row named bounded_glob_claim tests 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.

## C6 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. - **Committed**: `1aa6514` (the migration, guest SDK, grant path, declarative retirement), `bff9f15`, `a5f91c2`, `01a478b`. - **Wired**: `ruby_package_parity` and `ruby_package_cost` run in the `corpus` job under `COSI_CORPUS_REQUIRE=1`, alongside `upgrade_equivalence` — which the same work found had **never graded a repo**. - **Green**: run **584**, 10 jobs success / 3 skipped (nightly-only), including `cargo test`, `cargo test (daemon transport)` and `OSS 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-section` was necessary and not sufficient.** The CI image exports `CARGO_INCREMENTAL=1`, and incremental codegen is path-dependent *past* the name section, so no strip could rescue it. `incremental = false` in `[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_section` walks **every** checked-in `.wasm` and refuses any cargo-produced one still carrying a name section, discriminating on the decoded `producers` section rather than a substring. That matters for this issue because **any** guest built from `crates/guest` inherits 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 - **C2 (declarative tier) is retired by decision**, not left unmet — the amendment and the measurement for why deleting `Tier::Declarative` was not safely executable are in the comment above. - **Bounded path globs do not exist.** `claim::Key` is `Ext | Name | Suffix`, so this issue's *"extensions, exact basenames and bounded globs"* is two of three. The fixture row named `bounded_glob_claim` tests 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.
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.

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