index: generation-safe plugin activation, invalidation, promotion and rollback #78

Closed
opened 2026-08-26 12:32:33 +02:00 by buildagent · 5 comments
Member

Child of #75. Depends on #76, #77, #79, the refusal-state audit #72 and freshness repair #82.

Problem

Plugin package identity changes the meaning of unchanged source bytes. Content-only stat/hash reconciliation therefore cannot make plugin activation, upgrade or removal correct.

The previous proposal forced reparsing by extension and then recorded a rules hash. That does not cover a real plugin architecture:

  • claims may use exact basenames, bounded globs or embedded regions, not only extensions;
  • precedence changes can move a file between packages;
  • one file may contain multiple contributions;
  • a package upgrade may fail halfway;
  • parsing occurs outside SQLite and cannot be one transaction over a large project;
  • deleting/updating the current file row cannot represent old and new package generations simultaneously;
  • recording a fingerprint before convergence creates permanent stale state after a crash.

Outcome

Plugin activation is a generation build followed by an atomic visibility switch. Queries see the complete old generation or the complete new generation, never a partially reparsed mixture. Upgrade, disable, rollback and removal are first-class state transitions with kill -9 recovery.

Identity

The activation input digest is a canonical length-prefixed hash over:

  • exact package digest(s);
  • ordered claim graph and precedence;
  • project-approved capability grants;
  • language profiles and bridge grants;
  • host ABI and plugin-host engine version;
  • declarative rule-engine version;
  • source-map/embedded-dispatch semantics version;
  • project configuration values that affect extraction or resolution.

Package semantic version is not sufficient. Editing bytes without bumping a version still changes identity.

The digest is project-specific because the same installed package may receive different capabilities or coexist with different packages in another linked project.

State model

Use explicit durable states, not absent-key conventions:

  • requested: desired activation recorded but no build started;
  • building: pending generation exists and work is durable;
  • validating: extraction complete, resolver/conformance gates running;
  • ready: complete generation eligible for promotion;
  • active: visible to normal queries;
  • failed: rejected with stable reason and retained diagnostics;
  • superseded: previous active generation retained for rollback;
  • deleting: no longer requested, cleanup pending.

Only one active generation exists per project plugin-set identity. A failed/pending generation never becomes visible through ordinary symbol/ref/graph queries.

State transitions are transactions with monotonic sequence numbers. Recovery derives the next action from durable state; it does not guess from file mtimes.

Storage strategy

Generation membership must be explicit on contributions and all derived resolver state.

Two acceptable implementation families:

  1. generation columns plus active-generation predicates; or
  2. generation-scoped shadow tables atomically swapped through a small routing table/view.

Choose by measurement, but required properties are fixed:

  • old and pending rows can coexist;
  • queries cannot accidentally omit the active predicate;
  • ref target ids never cross incompatible generations;
  • aggregates, symbol edges, FTS-derived package facts and resolver diagnostics switch consistently;
  • rollback does not require reparsing;
  • garbage collection is separate from promotion.

A structural gate must enumerate every table/view that contains generation-derived data. Adding a new derived table without generation policy fails CI.

Source text FTS that is independent of extraction may remain shared, but plugin-generated semantic rows may not.

Claim-domain reconciliation

A package activation computes the affected domain from old and new ordered claims:

  • extensions;
  • exact basenames;
  • bounded path globs;
  • prior contribution provenance;
  • embedded-language wrapper claims;
  • host-language component dependencies;
  • precedence winners before and after the change.

The dirty set is the union of:

  • files matched by the old claim;
  • files matched by the new claim;
  • files with a contribution from the old generation;
  • wrapper files whose embedded dispatch target changed;
  • files whose resolver profile/bridge/capability change alters semantics even if extraction bytes are identical.

This handles text to code, code to text, package A to B, and contribution removal. It is not restricted to kind='code'.

Path eligibility itself becomes project/plugin-set dependent. Global plugin_set/is_code_path statics cannot answer it. Coverage and freshness APIs must route through the project’s active/requested plugin state and distinguish:

  • active coverage;
  • pending activation coverage;
  • permanently unclaimed;
  • rejected/quarantined package;
  • unavailable because an older daemon cannot report.

Build pipeline

  1. Resolve requested exact package digests from the local user registry.
  2. Validate package/ABI without execution (#76).
  3. Persist requested activation identity and pending generation.
  4. Start/validate workers through #79.
  5. Compute old/new claim-domain dirty set.
  6. Parse affected files into generation-scoped contributions.
  7. Validate every file response in the parent.
  8. Build generation-scoped resolver pools, refs, aggregates and diagnostics under #77.
  9. Run activation gates from #80, including positive controls.
  10. Mark generation ready.
  11. Atomically switch the project’s active generation pointer and active package metadata.
  12. Notify/readers or bump an epoch so caches cannot serve the old routing decision.
  13. Retain previous generation for bounded rollback; asynchronously garbage-collect older generations.

No query-visible state changes before step 11.

Source changes during build

A generation build may overlap watcher changes. Define a high-water protocol:

  • record source snapshot identity per file when dispatched;
  • validate mtime/ctime/size/hash before accepting the response;
  • if changed, discard and redispatch;
  • persist a bounded pending work queue;
  • promotion requires no outstanding dirty files relative to a final reconciliation epoch;
  • sustained churn has a deadline and fails/defer activation rather than promoting a known mixture.

Do not hold the normal writer lock for plugin parsing. Promotion is short; heavy parse/resolve work is generation-scoped.

Resolution and ids

Pending generation symbol ids must not leak through normal tools or stable handles.

At promotion:

  • active queries route to new ids atomically;
  • stable handles resolve against active generation only and may report moved/changed;
  • cursors include active-generation fingerprint and invalidate cleanly after promotion;
  • cached repo maps/context packs cannot mix epochs;
  • daemon RPC health exposes active and pending activation identities.

Rollback performs the same epoch transition in reverse.

Failure and rollback

Failures preserve the prior active generation:

  • package missing/digest mismatch;
  • ABI rejection;
  • worker timeout/crash/quarantine;
  • invalid fact response;
  • per-file budget/parse threshold exceeded;
  • resolver capability/inertness failure;
  • source churn deadline;
  • SQLite busy/space/resource exhaustion;
  • daemon crash.

Failed generation diagnostics remain inspectable but bounded. A retry creates a new attempt identity; it does not mutate a failed record into success.

Rollback requires no package execution while the superseded generation is retained. Removal activates a generation without that package, proves the old contribution absent, then garbage-collects it.

Disk-space admission is checked before build. If old+pending generations exceed a configured ceiling, activation refuses before damaging the active generation.

Daemon and multi-process coordination

  • One activation owner per project root identity.
  • CLI never writes the default DB behind a reachable daemon.
  • Plugin install registry locking is separate from project generation locking.
  • Daemon health includes schema, active generation, pending generation, activation state and plugin-host ABI.
  • MCP server/daemon skew has explicit normalization; absence is unavailable, not “no plugins.”
  • Linked projects may activate different package sets simultaneously; no process-global plugin_set or language registry may decide project eligibility.

Cold/incremental equivalence

For activation identity G:

cold index(source tree, G)
  ==
cold index(source tree, previous) then activate G
  ==
watcher-converged index after activate G

Compare canonical projections of:

  • file contributions and diagnostics;
  • symbols, refs and imports;
  • target ids normalized through stable fact identity;
  • resolved_by and dynamic influence;
  • ref counts and symbol edges;
  • coverage and project overview disclosures.

Positive controls first prove that changing the package actually changes the expected projection.

Crash matrix

Inject process death after every durable transition and meaningful batch:

  • request persisted;
  • generation allocated;
  • dirty set persisted;
  • partial extraction batches;
  • extraction complete;
  • partial resolution;
  • validation ready;
  • immediately before/after active pointer switch;
  • cache/epoch notification;
  • during rollback/removal;
  • during garbage collection.

After restart, the active query projection is exactly old or new and doctor explains/resumes/cleans pending state. No manual database deletion is an accepted repair.

Performance

Measure on 100k-file and high-ref fixtures:

  • dirty-domain computation;
  • additional DB/disk cost of old+pending generations;
  • activation parse and resolve wall time;
  • promotion lock duration;
  • normal query overhead of generation predicates;
  • watcher latency during activation;
  • rollback latency;
  • garbage-collection cost.

A plugin profile/bridge change that requires full resolution must use the existing long-operation busy timeout/cancellation discipline and a hard work budget. It degrades/fails activation with disclosure instead of wedging the daemon.

Tests

Four original transitions remain mandatory, generalized beyond extensions:

  • add claim over formerly unindexed files;
  • add claim over text-only files;
  • edit active package with unchanged source;
  • remove package/contribution.

Also:

  • precedence swap between packages;
  • exact-basename and glob claims;
  • embedded wrapper with multiple producers;
  • capability-only and bridge-only changes;
  • engine/ABI version change with identical package bytes;
  • source edit during build;
  • package crash/timeouts mid-build;
  • disk-full/busy cancellation;
  • rollback without executing the failed package;
  • per-project package-set isolation;
  • old/new daemon and client skew.

Acceptance

  1. Every interpretation change yields a new activation identity.
  2. All old/new claim-domain files are reconsidered, not just code files or matching extensions.
  3. Normal queries observe no pending rows.
  4. Promotion and rollback are atomic across facts, resolutions, aggregates and disclosures.
  5. kill -9 at every injected phase recovers without database deletion.
  6. Cold, activated and watcher-converged projections are equivalent.
  7. Failed/hanging packages cannot replace or corrupt the last good generation.
  8. Package removal leaves zero active contributions and rollback works while retention permits.

Existing reclassification fix to preserve

Comments 5353–5354 confirm the former StatTouch corruption experimentally and record its immediate fix in 4463d60: a code/text classification mismatch cannot take the hash-tier touch shortcut and instead flows through full upsert extraction. That fix is a permanent lower-level invariant, not superseded by generation activation.

Generation tests must retain both positive controls: unchanged content+classification may touch cheaply; changed classification must fully re-extract and purge the prior contribution. Package rule changes with unchanged classification still require the generation identity/build path above.

Child of #75. Depends on #76, #77, #79, the refusal-state audit #72 and freshness repair #82. ## Problem Plugin package identity changes the meaning of unchanged source bytes. Content-only stat/hash reconciliation therefore cannot make plugin activation, upgrade or removal correct. The previous proposal forced reparsing by extension and then recorded a rules hash. That does not cover a real plugin architecture: - claims may use exact basenames, bounded globs or embedded regions, not only extensions; - precedence changes can move a file between packages; - one file may contain multiple contributions; - a package upgrade may fail halfway; - parsing occurs outside SQLite and cannot be one transaction over a large project; - deleting/updating the current file row cannot represent old and new package generations simultaneously; - recording a fingerprint before convergence creates permanent stale state after a crash. ## Outcome Plugin activation is a generation build followed by an atomic visibility switch. Queries see the complete old generation or the complete new generation, never a partially reparsed mixture. Upgrade, disable, rollback and removal are first-class state transitions with kill -9 recovery. ## Identity The activation input digest is a canonical length-prefixed hash over: - exact package digest(s); - ordered claim graph and precedence; - project-approved capability grants; - language profiles and bridge grants; - host ABI and plugin-host engine version; - declarative rule-engine version; - source-map/embedded-dispatch semantics version; - project configuration values that affect extraction or resolution. Package semantic version is not sufficient. Editing bytes without bumping a version still changes identity. The digest is project-specific because the same installed package may receive different capabilities or coexist with different packages in another linked project. ## State model Use explicit durable states, not absent-key conventions: - requested: desired activation recorded but no build started; - building: pending generation exists and work is durable; - validating: extraction complete, resolver/conformance gates running; - ready: complete generation eligible for promotion; - active: visible to normal queries; - failed: rejected with stable reason and retained diagnostics; - superseded: previous active generation retained for rollback; - deleting: no longer requested, cleanup pending. Only one active generation exists per project plugin-set identity. A failed/pending generation never becomes visible through ordinary symbol/ref/graph queries. State transitions are transactions with monotonic sequence numbers. Recovery derives the next action from durable state; it does not guess from file mtimes. ## Storage strategy Generation membership must be explicit on contributions and all derived resolver state. Two acceptable implementation families: 1. generation columns plus active-generation predicates; or 2. generation-scoped shadow tables atomically swapped through a small routing table/view. Choose by measurement, but required properties are fixed: - old and pending rows can coexist; - queries cannot accidentally omit the active predicate; - ref target ids never cross incompatible generations; - aggregates, symbol edges, FTS-derived package facts and resolver diagnostics switch consistently; - rollback does not require reparsing; - garbage collection is separate from promotion. A structural gate must enumerate every table/view that contains generation-derived data. Adding a new derived table without generation policy fails CI. Source text FTS that is independent of extraction may remain shared, but plugin-generated semantic rows may not. ## Claim-domain reconciliation A package activation computes the affected domain from old and new ordered claims: - extensions; - exact basenames; - bounded path globs; - prior contribution provenance; - embedded-language wrapper claims; - host-language component dependencies; - precedence winners before and after the change. The dirty set is the union of: - files matched by the old claim; - files matched by the new claim; - files with a contribution from the old generation; - wrapper files whose embedded dispatch target changed; - files whose resolver profile/bridge/capability change alters semantics even if extraction bytes are identical. This handles text to code, code to text, package A to B, and contribution removal. It is not restricted to kind='code'. Path eligibility itself becomes project/plugin-set dependent. Global plugin_set/is_code_path statics cannot answer it. Coverage and freshness APIs must route through the project’s active/requested plugin state and distinguish: - active coverage; - pending activation coverage; - permanently unclaimed; - rejected/quarantined package; - unavailable because an older daemon cannot report. ## Build pipeline 1. Resolve requested exact package digests from the local user registry. 2. Validate package/ABI without execution (#76). 3. Persist requested activation identity and pending generation. 4. Start/validate workers through #79. 5. Compute old/new claim-domain dirty set. 6. Parse affected files into generation-scoped contributions. 7. Validate every file response in the parent. 8. Build generation-scoped resolver pools, refs, aggregates and diagnostics under #77. 9. Run activation gates from #80, including positive controls. 10. Mark generation ready. 11. Atomically switch the project’s active generation pointer and active package metadata. 12. Notify/readers or bump an epoch so caches cannot serve the old routing decision. 13. Retain previous generation for bounded rollback; asynchronously garbage-collect older generations. No query-visible state changes before step 11. ## Source changes during build A generation build may overlap watcher changes. Define a high-water protocol: - record source snapshot identity per file when dispatched; - validate mtime/ctime/size/hash before accepting the response; - if changed, discard and redispatch; - persist a bounded pending work queue; - promotion requires no outstanding dirty files relative to a final reconciliation epoch; - sustained churn has a deadline and fails/defer activation rather than promoting a known mixture. Do not hold the normal writer lock for plugin parsing. Promotion is short; heavy parse/resolve work is generation-scoped. ## Resolution and ids Pending generation symbol ids must not leak through normal tools or stable handles. At promotion: - active queries route to new ids atomically; - stable handles resolve against active generation only and may report moved/changed; - cursors include active-generation fingerprint and invalidate cleanly after promotion; - cached repo maps/context packs cannot mix epochs; - daemon RPC health exposes active and pending activation identities. Rollback performs the same epoch transition in reverse. ## Failure and rollback Failures preserve the prior active generation: - package missing/digest mismatch; - ABI rejection; - worker timeout/crash/quarantine; - invalid fact response; - per-file budget/parse threshold exceeded; - resolver capability/inertness failure; - source churn deadline; - SQLite busy/space/resource exhaustion; - daemon crash. Failed generation diagnostics remain inspectable but bounded. A retry creates a new attempt identity; it does not mutate a failed record into success. Rollback requires no package execution while the superseded generation is retained. Removal activates a generation without that package, proves the old contribution absent, then garbage-collects it. Disk-space admission is checked before build. If old+pending generations exceed a configured ceiling, activation refuses before damaging the active generation. ## Daemon and multi-process coordination - One activation owner per project root identity. - CLI never writes the default DB behind a reachable daemon. - Plugin install registry locking is separate from project generation locking. - Daemon health includes schema, active generation, pending generation, activation state and plugin-host ABI. - MCP server/daemon skew has explicit normalization; absence is unavailable, not “no plugins.” - Linked projects may activate different package sets simultaneously; no process-global plugin_set or language registry may decide project eligibility. ## Cold/incremental equivalence For activation identity G: cold index(source tree, G) == cold index(source tree, previous) then activate G == watcher-converged index after activate G Compare canonical projections of: - file contributions and diagnostics; - symbols, refs and imports; - target ids normalized through stable fact identity; - resolved_by and dynamic influence; - ref counts and symbol edges; - coverage and project overview disclosures. Positive controls first prove that changing the package actually changes the expected projection. ## Crash matrix Inject process death after every durable transition and meaningful batch: - request persisted; - generation allocated; - dirty set persisted; - partial extraction batches; - extraction complete; - partial resolution; - validation ready; - immediately before/after active pointer switch; - cache/epoch notification; - during rollback/removal; - during garbage collection. After restart, the active query projection is exactly old or new and doctor explains/resumes/cleans pending state. No manual database deletion is an accepted repair. ## Performance Measure on 100k-file and high-ref fixtures: - dirty-domain computation; - additional DB/disk cost of old+pending generations; - activation parse and resolve wall time; - promotion lock duration; - normal query overhead of generation predicates; - watcher latency during activation; - rollback latency; - garbage-collection cost. A plugin profile/bridge change that requires full resolution must use the existing long-operation busy timeout/cancellation discipline and a hard work budget. It degrades/fails activation with disclosure instead of wedging the daemon. ## Tests Four original transitions remain mandatory, generalized beyond extensions: - add claim over formerly unindexed files; - add claim over text-only files; - edit active package with unchanged source; - remove package/contribution. Also: - precedence swap between packages; - exact-basename and glob claims; - embedded wrapper with multiple producers; - capability-only and bridge-only changes; - engine/ABI version change with identical package bytes; - source edit during build; - package crash/timeouts mid-build; - disk-full/busy cancellation; - rollback without executing the failed package; - per-project package-set isolation; - old/new daemon and client skew. ## Acceptance 1. Every interpretation change yields a new activation identity. 2. All old/new claim-domain files are reconsidered, not just code files or matching extensions. 3. Normal queries observe no pending rows. 4. Promotion and rollback are atomic across facts, resolutions, aggregates and disclosures. 5. kill -9 at every injected phase recovers without database deletion. 6. Cold, activated and watcher-converged projections are equivalent. 7. Failed/hanging packages cannot replace or corrupt the last good generation. 8. Package removal leaves zero active contributions and rollback works while retention permits. ## Existing reclassification fix to preserve Comments 5353–5354 confirm the former StatTouch corruption experimentally and record its immediate fix in 4463d60: a code/text classification mismatch cannot take the hash-tier touch shortcut and instead flows through full upsert extraction. That fix is a permanent lower-level invariant, not superseded by generation activation. Generation tests must retain both positive controls: unchanged content+classification may touch cheaply; changed classification must fully re-extract and purge the prior contribution. Package rule changes with unchanged classification still require the generation identity/build path above.
Author
Member

Both experiments RUN. Both CONFIRMED.

Executed against Writer::commit_batch with a real in-memory schema (db::open_in_memory), driving WriterMsg::StatTouch directly — the same path run_task takes at index.rs:5425 / :5498. Temporary tests were injected into writer.rs's test module, run, then the file was restored from snapshot and md5-verified (crates/indexer/src/writer.rs: OK, git status clean).

Experiment 1 — text → code produces a confident EMPTY row

XP1 before: kind=text  symbols=0  outline_hash=false
XP1 after : kind=code  lang=rust  symbols=0  outline_hash=false
XP1 VERDICT: CONFIRMED — kind='code', zero symbols, never parsed

The row now claims to be code, reports zero symbols, and still carries no outline_hash. Downstream that reads as overlap.is_code = true, index_coverage: indexed / indexed_as: code, freshness not-stale — and changed_symbols reporting a symbol-bearing file as symbol-free. Indistinguishable from a genuinely empty file.

The missing outline_hash is a second-order finding worth noting on its own: writer.rs:317-320 keeps files without one out of the resolve scope entirely, so such a row is not merely empty — it is invisible to resolution as well.

Experiment 2 — removal orphans every symbol

XP2 before: kind=code  symbols=2
XP2 after : kind=text  symbols=2
XP2 VERDICT: CONFIRMED — orphan symbols survive a code->text flip

write_stat_touch is a bare UPDATE files SET mtime_ns, ctime_ns, size, lang, kind (writer.rs:359-364). Symbols cascade on DELETE FROM files (writer.rs:238-241), not on UPDATE. So the file is reclassified as text while its symbol rows stay live and reachable — searchable, resolvable, and attributable to a file that no longer claims to have any.

The test that should have caught this already exists

touch_restamps_lang_and_kind_when_classification_changed (writer.rs:869+) performs exactly the Experiment 2 flip — ("rust", Code) → ("md", Text) — and asserts only lang and kind. It never looks at symbols. Its own doc anticipates the trigger verbatim: "a plugin can be added/removed". The mechanism was tested; its consequence was not.

That is the cheapest available fix for the near term, independent of the rest of this issue: extend that test to assert the symbol population, and it will fail today.

Consequence for the contract

T3's invalidation must clear the hash tier, not just mtime_ns — only a forced full re-parse routes through write_upsert's DELETE+INSERT and purges the orphans. An mtime_ns = -1-only invalidation lands back on the StatTouch path and reproduces both outcomes above.

## Both experiments RUN. Both CONFIRMED. Executed against `Writer::commit_batch` with a real in-memory schema (`db::open_in_memory`), driving `WriterMsg::StatTouch` directly — the same path `run_task` takes at `index.rs:5425` / `:5498`. Temporary tests were injected into `writer.rs`'s test module, run, then the file was restored from snapshot and **md5-verified** (`crates/indexer/src/writer.rs: OK`, `git status` clean). ### Experiment 1 — text → code produces a confident EMPTY row ``` XP1 before: kind=text symbols=0 outline_hash=false XP1 after : kind=code lang=rust symbols=0 outline_hash=false XP1 VERDICT: CONFIRMED — kind='code', zero symbols, never parsed ``` The row now claims to be code, reports zero symbols, and still carries **no `outline_hash`**. Downstream that reads as `overlap.is_code = true`, `index_coverage: indexed / indexed_as: code`, freshness not-stale — and `changed_symbols` reporting a symbol-bearing file as symbol-free. **Indistinguishable from a genuinely empty file.** The missing `outline_hash` is a second-order finding worth noting on its own: `writer.rs:317-320` keeps files without one out of the resolve scope entirely, so such a row is not merely empty — it is invisible to resolution as well. ### Experiment 2 — removal orphans every symbol ``` XP2 before: kind=code symbols=2 XP2 after : kind=text symbols=2 XP2 VERDICT: CONFIRMED — orphan symbols survive a code->text flip ``` `write_stat_touch` is a bare `UPDATE files SET mtime_ns, ctime_ns, size, lang, kind` (`writer.rs:359-364`). Symbols cascade on `DELETE FROM files` (`writer.rs:238-241`), **not on UPDATE**. So the file is reclassified as text while its symbol rows stay live and reachable — searchable, resolvable, and attributable to a file that no longer claims to have any. ### The test that should have caught this already exists `touch_restamps_lang_and_kind_when_classification_changed` (`writer.rs:869+`) performs **exactly** the Experiment 2 flip — `("rust", Code)` → `("md", Text)` — and asserts only `lang` and `kind`. It never looks at `symbols`. Its own doc anticipates the trigger verbatim: *"a plugin can be added/removed"*. The mechanism was tested; its consequence was not. That is the cheapest available fix for the near term, independent of the rest of this issue: **extend that test to assert the symbol population**, and it will fail today. ### Consequence for the contract T3's invalidation must clear the **hash** tier, not just `mtime_ns` — only a forced full re-parse routes through `write_upsert`'s DELETE+INSERT and purges the orphans. An `mtime_ns = -1`-only invalidation lands back on the StatTouch path and reproduces both outcomes above.
Author
Member

Fixed on master — 4463d60

Both measured failures are closed. The rest of this issue (the rule-set fingerprint, T1–T6, the metamorphic suite) is still open — this only removes the corruption that would have fired the moment anything flipped a classification.

The fix

One condition, both directions, at the decision point rather than in the writer.

FileSnapshot now carries is_code (from files.kind), and both hash-tier shortcuts — run_task and run_text_only_task — require it to match the classification the task is claiming. A mismatch falls through to the full parse, where write_upsert's DELETE+INSERT purges the stale rows and writes whatever the new classification actually produces.

The writer is deliberately left alone. StatTouch means "content unchanged, do not re-extract"; it cannot purge what it never re-derives, and making it try would put extraction logic in the writer.

Consequence for T3

The experiments settle a detail of the proposed contract: T3 must clear the hash tier, not just mtime_ns. An mtime_ns = -1-only invalidation lands back on the StatTouch path — which, with this fix in place, now correctly refuses and re-parses on a classification change, but still touches when the classification is unchanged and only the rules moved. The fingerprint remains necessary.

Tests

  • reclassification_is_not_a_touch (index.rs) asserts != StatTouch — the contract is "do not take the shortcut", not any particular message — with a control asserting the unchanged case still does take it, so the test cannot pass by disabling the hash tier outright. Mutation-checked: removing the guard fails it and prints the StatTouch it should have refused.
  • touch_restamps_lang_and_kind_when_classification_changed now asserts the symbol population. Worth recording why the obvious version of that assertion would have been worthless: its fixture used outcome(), which produces zero symbols, so assert_eq!(symbols, 0) would have been vacuously true. It now writes two symbols, asserts them as a precondition, and pins that the writer does not purge — with a comment naming run_task as where the invariant actually lives, so the two cannot drift.

Gate: 1405 tests (+1), 93 suites, 0 failures; fmt and clippy clean at -D warnings. Not yet pushed.

## Fixed on master — `4463d60` Both measured failures are closed. The rest of this issue (the rule-set fingerprint, T1–T6, the metamorphic suite) is **still open** — this only removes the corruption that would have fired the moment anything flipped a classification. ### The fix One condition, both directions, at the decision point rather than in the writer. `FileSnapshot` now carries `is_code` (from `files.kind`), and both hash-tier shortcuts — `run_task` and `run_text_only_task` — require it to match the classification the task is claiming. A mismatch falls through to the full parse, where `write_upsert`'s DELETE+INSERT purges the stale rows and writes whatever the new classification actually produces. The writer is deliberately left alone. `StatTouch` means *"content unchanged, do not re-extract"*; it cannot purge what it never re-derives, and making it try would put extraction logic in the writer. ### Consequence for T3 The experiments settle a detail of the proposed contract: **T3 must clear the hash tier, not just `mtime_ns`.** An `mtime_ns = -1`-only invalidation lands back on the StatTouch path — which, with this fix in place, now correctly refuses and re-parses on a classification change, but still touches when the classification is unchanged and only the rules moved. The fingerprint remains necessary. ### Tests - **`reclassification_is_not_a_touch`** (`index.rs`) asserts `!= StatTouch` — the contract is "do not take the shortcut", not any particular message — with a **control** asserting the unchanged case still *does* take it, so the test cannot pass by disabling the hash tier outright. Mutation-checked: removing the guard fails it and prints the `StatTouch` it should have refused. - **`touch_restamps_lang_and_kind_when_classification_changed`** now asserts the symbol population. Worth recording why the obvious version of that assertion would have been worthless: its fixture used `outcome()`, which produces **zero symbols**, so `assert_eq!(symbols, 0)` would have been vacuously true. It now writes two symbols, asserts them as a precondition, and pins that the writer does **not** purge — with a comment naming `run_task` as where the invariant actually lives, so the two cannot drift. Gate: 1405 tests (+1), 93 suites, 0 failures; fmt and clippy clean at `-D warnings`. Not yet pushed.
buildagent changed title from index: a rule-set edit never invalidates anything — corruption-class blocker for runtime formats to index: generation-safe plugin activation, invalidation, promotion and rollback 2026-08-26 13:32:56 +02:00
Author
Member

Residual sweep, 2026-09-04 — two closed, one refuted, four filed

Seven residuals were put to this session. Each was verified against the tree before being acted on, and two of the seven were not what they said they were.

1. Engine flags outside the activation identity — CLOSED

The audit's framing was "a stale .cwasm is silently reused". That premise is false for the shipped path, and the tree already says so: PackageSet holds a StagingDir created per set under the system temp dir (code-index-plugin-stage.<pid>.<seq>.<nanos>), load_one precompiles into it, and Drop is remove_dir_all. packages.rs's module doc puts it plainly — "a .cwasm is rebuilt every time a host starts, so there is no stale artifact for a generation to inherit". Nothing caches an artifact across a process.

The real half was the identity, and it was live. HostIdentity::engine carried WASM_ENGINE = "wasmtime 36.0.14" and nothing else, so editing engine_config() moved neither the identity nor — measured, for three of the eight flags — Module::deserialize's verdict. max_wasm_stack in particular decides whether a deeply nested file traps or extracts, so two indexes with genuinely different content claimed one activation identity. Same class as ledger C10/H3, and engine.rs's own module doc had already drawn the conclusion: "Whatever key #78 uses has to CARRY THE FLAGS ITSELF."

It does now, by one mechanism serving both sides:

  • engine::EngineFlags + ENGINE_FLAGS is the single list every flag is applied from (apply) and spelled from (render), both destructuring self with no .. — the compiler-is-the-test rule activation_digest already uses.
  • EngineFlags::config hands WASM_ENGINE + the rendered flags to Config::module_version(ModuleVersionStrategy::Custom(..)), which Module::deserialize compares byte for byte. The engine version is kept inside that string because Custom replaces wasmtime's own version check rather than adding to it; dropping it would have been a relaxation.
  • protocol::WASM_ENGINE_FLAGS carries the same string parent-side (that crate links no runtime), reported by the worker as HostBuild::engine_flags and composed into identity_line — so what enters the identity is the flags of the binary that will actually compile and run, not the parent's guess, exactly as S32 did for the version. REPORT_MAJOR 1 → 2, because a missing known key is a fault and the addition is therefore not additive.
  • conform::host_engine() was the same defect one site over — a stored conformance verdict was "current" under changed flags, on the same stated reasoning — and now returns version + flags.

cranelift_opt_level stays outside the key, with an argument rather than a shrug: this host never sets it (the setter is behind wasmtime's cranelift feature, which the shipped dependency line does not ask for), so it is wasmtime's default and moves only with the wasmtime version — which the key carries. flags_gate::the_engine_config_sets_nothing_the_flags_do_not_carry fails the build if that stops being true.

Mutation (run). In EngineFlags::config, compute the key and do not bake it — the pre-fix world exactly. RED:

a `.cwasm` compiled under a different setting of ["stack", "backtrace"] LOADED on a
host built with `ENGINE_FLAGS`. #78's activation identity carries these flags, so a
host that also MAPS a foreign artifact under them is the cache-coherence gap
`engine.rs`'s module doc was written about — reopened

Those are precisely the two flags the original measurement listed as silently accepted. Positive control runs first in the same body: the unchanged flags must compile, load and run, and an anti-vacuity assert requires one bend per rendered field.

The partition test a_foreign_cwasm_is_refused_for_exactly_the_flags_that_are_baked_in is unchanged and still 5/3 — it now measures what stock wasmtime gives for free, with the key held constant, and its doc says why the two tests are kept apart.

2. Cursors carry no active-generation fingerprint — VERIFIED, filed as #127

Real, and the recorded deferral rationale in epoch.rs was stale on both its premises: the production promoter shipped in v0.23.0 (cli/src/activation.rs, two sites, commit 1a6a431), and the cost objection ("only reachable through stats, a full-table count sweep") is wrong by three orders of magnitude — index_coverage has carried the epoch since S49 and ReadEpoch::probe is measured at 8.5-10.2 µs. The doc is corrected in the tree. Not fixed here because what remains is a wire change to two paginated replies with both skew directions, which is its own piece of work. #127 carries the full measurement, including that the hazard is cross-session (both promote paths refuse behind a live daemon) and that it is not benign when reached.

3. stats is not snapshot-read — REFRAMED and CLOSED at the real defect

As stated it is not actionable: no daemon RPC arm is snapshot-read, there is no read-transaction helper anywhere on that path, and stats' opening comment argues the choice explicitly ("far too expensive here… pointed the safe way").

The defect underneath is smaller, real, and a violation of a rule this codebase already states. stats probes a ReadEpoch, gates a dozen counts with it, and then — thirteen statements later, in autocommit — called read_activation, which re-read WHERE state = 'active' itself. local_index::read_file_claim states the rule verbatim: "active_generation must be the SAME id the rest of this reply's rows were gated to. Re-reading it would let a promotion land between the two and produce a reply whose symbols came from one generation and whose claim named another." index_coverage obeyed it; stats did not. Promotion NULLs target_id on the outgoing generation's refs, so a straddling reply can report zero resolved references beside a generation that had thousands.

read_identity/read_activation now take pinned_active: Option<i64> — Some = "the caller has already gated other rows to this id", None = "the caller gates nothing else" (that is health, whose only generation-derived content is this block, and the two CLI readers). Both arms keep the no-FROM scalar-subquery shape so a missing table and a missing row stay distinguishable. The three coverage counts that also selected the active generation by state follow the same pin.

Mutation (run). Take the unpinned arm unconditionally — the pre-fix body. RED: the block re-derived its own active generation: pinned to 5, reported Some(3). Positive control: pinning to the generation that is active must reproduce the unpinned read exactly, so the gate cannot be one that changes the unchanged case.

4. Daemon RPC health exposes no activation identity — REFUTED, already shipped

Health is not {root, schema_version}. It gained activation: Option<ActivationIdentity> (all five of #78's identity fields) and plugin_host_abi: Option<String> at #80 S52, commit d66552c, 2026-09-01 — three days before this session. The claim describes the S49 tree. It is covered by health_probe_e2e.rs (value-pinned against stats, plus a planner-based cost guard over both bodies), four skew legs in wire_skew_e2e.rs, and the RPC_METHODS gate.

5-7. Filed

  • #128 — invalid_fact fires on rusqlite errors. Verified and worse than stated: the bare Err(e) => at build.rs:745 sits over a dispatch_round whose body is ? on a dozen rusqlite calls, and reason_code_registry.rs registers storage_exhausted as no_producer with a justification that code contradicts. No test drives the arm; #78's own test list names "disk-full/busy cancellation" and nothing implements it.
  • #130 — repeated plugin rollback. Verified as a toggle, but the word "silently" is wrong: cmd_rollback prints the target generation before acting and plugin status prints the whole inventory. With SUPERSEDED_RETENTION_LIMIT = 1 there is nothing older to reach, so a second rollback is "the same epoch transition in reverse" applied to the new state. What is missing is a test pinning the toggle and one sentence of prose.
  • #129 — DAEMON_READERS enumeration backstop. Verified, and confirmed that no escapee exists today. The floor is a >= on a sum over the four registered files only, so an unregistered reader contributes 0 ungated and 0 sites. The read_dir-and-set-equality pattern already exists twice in this repo (bounding_site_registry, reason_code_registry/RPC_METHODS).
## Residual sweep, 2026-09-04 — two closed, one refuted, four filed Seven residuals were put to this session. Each was verified against the tree before being acted on, and **two of the seven were not what they said they were**. ### 1. Engine flags outside the activation identity — **CLOSED** The audit's framing was *"a stale `.cwasm` is silently reused"*. **That premise is false for the shipped path**, and the tree already says so: `PackageSet` holds a `StagingDir` created per set under the system temp dir (`code-index-plugin-stage.<pid>.<seq>.<nanos>`), `load_one` precompiles into it, and `Drop` is `remove_dir_all`. `packages.rs`'s module doc puts it plainly — *"a `.cwasm` is rebuilt every time a host starts, so there is no stale artifact for a generation to inherit"*. Nothing caches an artifact across a process. **The real half was the identity, and it was live.** `HostIdentity::engine` carried `WASM_ENGINE` = `"wasmtime 36.0.14"` and nothing else, so editing `engine_config()` moved neither the identity nor — measured, for three of the eight flags — `Module::deserialize`'s verdict. `max_wasm_stack` in particular decides whether a deeply nested file traps or extracts, so two indexes with genuinely different **content** claimed one activation identity. Same class as ledger C10/H3, and `engine.rs`'s own module doc had already drawn the conclusion: *"Whatever key #78 uses has to CARRY THE FLAGS ITSELF."* It does now, by one mechanism serving both sides: * `engine::EngineFlags` + `ENGINE_FLAGS` is the single list every flag is applied from (`apply`) and spelled from (`render`), both destructuring `self` with **no `..`** — the compiler-is-the-test rule `activation_digest` already uses. * `EngineFlags::config` hands `WASM_ENGINE` + the rendered flags to `Config::module_version(ModuleVersionStrategy::Custom(..))`, which `Module::deserialize` compares byte for byte. The engine version is kept **inside** that string because `Custom` *replaces* wasmtime's own version check rather than adding to it; dropping it would have been a relaxation. * `protocol::WASM_ENGINE_FLAGS` carries the same string parent-side (that crate links no runtime), reported by the worker as `HostBuild::engine_flags` and composed into `identity_line` — so what enters the identity is the flags of the binary that will actually compile and run, not the parent's guess, exactly as S32 did for the version. `REPORT_MAJOR` 1 → 2, because a missing known key is a fault and the addition is therefore not additive. * `conform::host_engine()` was the same defect one site over — a stored conformance verdict was "current" under changed flags, on the same stated reasoning — and now returns version + flags. `cranelift_opt_level` stays outside the key, with an argument rather than a shrug: this host never *sets* it (the setter is behind wasmtime's `cranelift` feature, which the shipped dependency line does not ask for), so it is wasmtime's default and moves only with the wasmtime version — which the key carries. `flags_gate::the_engine_config_sets_nothing_the_flags_do_not_carry` fails the build if that stops being true. **Mutation (run).** In `EngineFlags::config`, compute the key and do not bake it — the pre-fix world exactly. RED: ``` a `.cwasm` compiled under a different setting of ["stack", "backtrace"] LOADED on a host built with `ENGINE_FLAGS`. #78's activation identity carries these flags, so a host that also MAPS a foreign artifact under them is the cache-coherence gap `engine.rs`'s module doc was written about — reopened ``` Those are precisely the two flags the original measurement listed as silently accepted. **Positive control** runs first in the same body: the unchanged flags must compile, load *and* run, and an anti-vacuity assert requires one bend per rendered field. The partition test `a_foreign_cwasm_is_refused_for_exactly_the_flags_that_are_baked_in` is unchanged and still 5/3 — it now measures what stock wasmtime gives for free, with the key held constant, and its doc says why the two tests are kept apart. ### 2. Cursors carry no active-generation fingerprint — **VERIFIED, filed as #127** Real, and the recorded deferral rationale in `epoch.rs` was stale on **both** its premises: the production promoter shipped in v0.23.0 (`cli/src/activation.rs`, two sites, commit `1a6a431`), and the cost objection ("only reachable through `stats`, a full-table count sweep") is wrong by three orders of magnitude — `index_coverage` has carried the epoch since S49 and `ReadEpoch::probe` is measured at 8.5-10.2 µs. The doc is corrected in the tree. Not fixed here because what remains is a wire change to two paginated replies with both skew directions, which is its own piece of work. #127 carries the full measurement, including that the hazard is **cross-session** (both promote paths refuse behind a live daemon) and that it is *not* benign when reached. ### 3. `stats` is not snapshot-read — **REFRAMED and CLOSED at the real defect** As stated it is not actionable: **no daemon RPC arm is snapshot-read**, there is no read-transaction helper anywhere on that path, and `stats`' opening comment argues the choice explicitly ("far too expensive here… pointed the safe way"). The defect underneath is smaller, real, and a violation of a rule this codebase already states. `stats` probes a `ReadEpoch`, gates a dozen counts with it, and then — thirteen statements later, in autocommit — called `read_activation`, which **re-read `WHERE state = 'active'` itself**. `local_index::read_file_claim` states the rule verbatim: *"`active_generation` must be the SAME id the rest of this reply's rows were gated to. Re-reading it would let a promotion land between the two and produce a reply whose symbols came from one generation and whose claim named another."* `index_coverage` obeyed it; `stats` did not. Promotion NULLs `target_id` on the outgoing generation's refs, so a straddling reply can report zero resolved references beside a generation that had thousands. `read_identity`/`read_activation` now take `pinned_active: Option<i64>` — `Some` = "the caller has already gated other rows to this id", `None` = "the caller gates nothing else" (that is `health`, whose only generation-derived content is this block, and the two CLI readers). Both arms keep the no-`FROM` scalar-subquery shape so a missing **table** and a missing **row** stay distinguishable. The three coverage counts that also selected the active generation *by state* follow the same pin. **Mutation (run).** Take the unpinned arm unconditionally — the pre-fix body. RED: `the block re-derived its own active generation: pinned to 5, reported Some(3)`. **Positive control**: pinning to the generation that *is* active must reproduce the unpinned read exactly, so the gate cannot be one that changes the unchanged case. ### 4. Daemon RPC health exposes no activation identity — **REFUTED, already shipped** `Health` is not `{root, schema_version}`. It gained `activation: Option<ActivationIdentity>` (all five of #78's identity fields) and `plugin_host_abi: Option<String>` at **#80 S52**, commit `d66552c`, 2026-09-01 — three days before this session. The claim describes the S49 tree. It is covered by `health_probe_e2e.rs` (value-pinned against `stats`, plus a planner-based cost guard over both bodies), four skew legs in `wire_skew_e2e.rs`, and the `RPC_METHODS` gate. ### 5-7. Filed * **#128** — `invalid_fact` fires on rusqlite errors. **Verified and worse than stated**: the bare `Err(e) =>` at `build.rs:745` sits over a `dispatch_round` whose body is `?` on a dozen rusqlite calls, and `reason_code_registry.rs` registers `storage_exhausted` as `no_producer` with a justification that code contradicts. No test drives the arm; #78's own test list names "disk-full/busy cancellation" and nothing implements it. * **#130** — repeated `plugin rollback`. **Verified as a toggle, but the word "silently" is wrong**: `cmd_rollback` prints the target generation before acting and `plugin status` prints the whole inventory. With `SUPERSEDED_RETENTION_LIMIT = 1` there is nothing older to reach, so a second rollback *is* "the same epoch transition in reverse" applied to the new state. What is missing is a test pinning the toggle and one sentence of prose. * **#129** — `DAEMON_READERS` enumeration backstop. **Verified**, and confirmed that no escapee exists today. The floor is a `>=` on a sum over the four registered files only, so an unregistered reader contributes 0 ungated *and* 0 sites. The `read_dir`-and-set-equality pattern already exists twice in this repo (`bounding_site_registry`, `reason_code_registry`/`RPC_METHODS`).
Author
Member

Residuals audited. One was a false premise hiding a real defect next door, one was already shipped, one was not actionable as written.

Following #115 (a package refusal silently promoted over the last good generation), the remaining seven were worked in order. Each was verified first-hand before acting, which mattered: three of the seven were not what the audit described.

1. Engine flags — the serious one, and the audit's premise was FALSE

The claim was "a stale .cwasm is silently reused". That does not happen: PackageSet holds a per-set StagingDir whose Drop is remove_dir_all, and packages.rs says so. Nothing caches an artifact across a process.

The real defect was one field over, and it is worse than the framing suggested. HostIdentity::engine was the wasmtime version alone, so an engine_config() edit moved neither the identity nor — for 3 of 8 flags, measured — deserialize's verdict.

max_wasm_stack decides whether a deeply nested file traps or extracts. So two indexes holding different content could claim one identity. That is an activation-identity defect, not a caching one.

Fixed as one mechanism with both sides spelled from the same list: EngineFlags/ENGINE_FLAGS, with no .. rest pattern, so the compiler is the test. EngineFlags::config bakes engine + flags into the artifact via module_version(Custom(..)); the same value travels protocol::WASM_ENGINE_FLAGS → HostBuild::engine_flags → identity_line into the digest, reported by the worker as S32 did for the version. REPORT_MAJOR 1→2. conform::host_engine() was the same defect one site over.

cranelift_opt_level stays out, with an argument and a gate enforcing the argument: it is never set here, and its default moves only with the wasmtime version, which the key already carries.

The mutation is red on the reuse, not on a recomputed hash: strip module_version and a .cwasm compiled under different settings of ["stack", "backtrace"] LOADS — naming the exact two flags independently measured as silently accepted. Positive control (unchanged flags compile, load and run) executes first in the same body.

2. Cursors — verified, filed as #127

Real. Its recorded deferral rationale was stale on both premises: the production promoter shipped in v0.23.0, and "only reachable through stats, a count sweep" is wrong by three orders of magnitude (ReadEpoch::probe = 8.5–10.2 µs). Doc corrected in tree; what remains is a wire change to two paginated replies.

3. stats snapshot-read — not actionable as written, real defect found by reframing

No daemon RPC arm is snapshot-read, and stats argues that choice explicitly — so "make stats snapshot-read" is a change to a deliberate design, not a fix.

Reframing found the actual violation: read_activation re-read state='active' thirteen statements after the epoch gated the counts — breaking a rule read_file_claim states verbatim. Now pinned (pinned_active: Option<i64>, three-state, counts included). Mutation red: pinned to 5, reported Some(3), with a positive control.

4. Health activation identity — REFUTED

Already shipped at #80 S52, commit d66552c, three days ago. Health carries activation (all five fields) and plugin_host_abi. The audit and this issue both describe pre-fix code.

5–7 — filed rather than done thinly

  • #128 invalid_fact fires on rusqlite errors. Worse than stated: storage_exhausted is registered no_producer with a justification the code contradicts, so a disk failure is reported to an operator as a malformed fact — pointing them at the package instead of the disk.
  • #129 DAEMON_READERS enumeration backstop — verified, no live escapee today, and the pattern exists twice in-repo.
  • #130 rollback toggle — defensible behaviour; "silently" is wrong. It needs a test and a sentence, not a redesign.

Plus #131, found running the gates, now fixed

agent_task_benchmark_runtime_plugin was a coin flip: 13 isolated runs gave 7275 ten times and 7424/7455/7478 three times against a 7425.6 ceiling. The delta is package_set_unconsulted_semantics, ~190 tokens rendered only when the client beats package discovery — introduced by 78e2963 two days ago, so pre-existing.

The block is real when it appears, so the fix lets discovery settle (Phase::start waits for package_set_consulted) rather than widening the band or excluding the field. Now 6790 tokens, eight consecutive isolated runs, identical — and below the old 7072, because the recorded value had itself been taken from a raced payload.

Worth recording: the first version of that wait went green while being useless — it polled with response_format: "concise", which drops plugin_activation, so it read false forever and every phase paid the full 60s deadline. 181s for a 2s suite. The green result hid it; only the runtime gave it away.

## Residuals audited. One was a false premise hiding a real defect next door, one was already shipped, one was not actionable as written. Following #115 (a package refusal silently promoted over the last good generation), the remaining seven were worked in order. Each was **verified first-hand before acting**, which mattered: three of the seven were not what the audit described. ### 1. Engine flags — the serious one, and the audit's premise was FALSE The claim was *"a stale `.cwasm` is silently reused"*. That does not happen: `PackageSet` holds a per-set `StagingDir` whose `Drop` is `remove_dir_all`, and `packages.rs` says so. **Nothing caches an artifact across a process.** The real defect was one field over, and it is worse than the framing suggested. `HostIdentity::engine` was **the wasmtime version alone**, so an `engine_config()` edit moved neither the identity nor — for 3 of 8 flags, measured — `deserialize`'s verdict. **`max_wasm_stack` decides whether a deeply nested file traps or extracts.** So two indexes holding *different content* could claim one identity. That is an activation-identity defect, not a caching one. Fixed as one mechanism with both sides spelled from the same list: `EngineFlags`/`ENGINE_FLAGS`, with **no `..` rest pattern, so the compiler is the test**. `EngineFlags::config` bakes engine + flags into the artifact via `module_version(Custom(..))`; the same value travels `protocol::WASM_ENGINE_FLAGS` → `HostBuild::engine_flags` → `identity_line` into the digest, reported by the worker as S32 did for the version. `REPORT_MAJOR` 1→2. `conform::host_engine()` was the same defect one site over. `cranelift_opt_level` stays **out**, with an argument and a gate enforcing the argument: it is never set here, and its default moves only with the wasmtime version, which the key already carries. **The mutation is red on the reuse, not on a recomputed hash:** strip `module_version` and a `.cwasm` compiled under different settings of `["stack", "backtrace"]` **LOADS** — naming the exact two flags independently measured as silently accepted. Positive control (unchanged flags compile, load *and run*) executes first in the same body. ### 2. Cursors — verified, filed as #127 Real. Its recorded deferral rationale was stale on **both** premises: the production promoter shipped in v0.23.0, and *"only reachable through `stats`, a count sweep"* is wrong by three orders of magnitude (`ReadEpoch::probe` = 8.5–10.2 µs). Doc corrected in tree; what remains is a wire change to two paginated replies. ### 3. `stats` snapshot-read — not actionable as written, real defect found by reframing **No** daemon RPC arm is snapshot-read, and `stats` argues that choice explicitly — so "make `stats` snapshot-read" is a change to a deliberate design, not a fix. Reframing found the actual violation: `read_activation` re-read `state='active'` **thirteen statements after** the epoch gated the counts — breaking a rule `read_file_claim` states verbatim. Now pinned (`pinned_active: Option<i64>`, three-state, counts included). Mutation red: `pinned to 5, reported Some(3)`, with a positive control. ### 4. Health activation identity — REFUTED Already shipped at #80 S52, commit `d66552c`, three days ago. `Health` carries `activation` (all five fields) and `plugin_host_abi`. The audit and this issue both describe pre-fix code. ### 5–7 — filed rather than done thinly - **#128** `invalid_fact` fires on rusqlite errors. **Worse than stated**: `storage_exhausted` is registered `no_producer` with a justification the code contradicts, so a disk failure is reported to an operator as a malformed fact — pointing them at the package instead of the disk. - **#129** `DAEMON_READERS` enumeration backstop — verified, no live escapee today, and the pattern exists twice in-repo. - **#130** rollback toggle — defensible behaviour; **"silently" is wrong**. It needs a test and a sentence, not a redesign. ### Plus #131, found running the gates, now fixed `agent_task_benchmark_runtime_plugin` was a **coin flip**: 13 isolated runs gave 7275 ten times and 7424/7455/7478 three times against a 7425.6 ceiling. The delta is `package_set_unconsulted_semantics`, ~190 tokens rendered only when the client beats package discovery — introduced by `78e2963` two days ago, so pre-existing. The block is **real when it appears**, so the fix lets discovery settle (`Phase::start` waits for `package_set_consulted`) rather than widening the band or excluding the field. Now **6790 tokens, eight consecutive isolated runs, identical** — and *below* the old 7072, because the recorded value had itself been taken from a raced payload. Worth recording: the first version of that wait went green while being useless — it polled with `response_format: "concise"`, which drops `plugin_activation`, so it read `false` forever and every phase paid the full 60s deadline. **181s for a 2s suite.** The green result hid it; only the runtime gave it away.
Author
Member

All eight criteria met. Closing on 01a478b, CI run 584 green.

Criterion 7 was the live defect and it was the serious one; the rest were already met or were met by the residual sweep. Walking them:

# criterion state
1 every interpretation change yields a new activation identity MET — and it was not met until 01a478b. See below.
2 all old/new claim-domain files reconsidered MET — dirty_domain, graded by the claim-domain suites
3 normal queries observe no pending rows MET — the reader epoch, 83 daemon sites gated
4 promotion and rollback atomic across facts, resolutions, aggregates and disclosures MET, with one real violation found and fixed in the sweep — read_activation re-read state='active' thirteen statements after the epoch gated the counts, breaking a rule read_file_claim states verbatim. Now pinned three-state.
5 kill -9 at every injected phase recovers without database deletion MET — the crash matrix over all 12 durable transitions
6 cold == activated == watcher-converged MET as a projection triangle over five id-independent projections. Stated honestly: the identity half is excluded from comparison by design (generation_equivalence.rs:113-121), so this is equivalence of what the generations hold, not of what they are called.
7 failed/hanging packages cannot replace or corrupt the last good generation MET — #115
8 package removal leaves zero active contributions; rollback works while retention permits MET — and #130 added the missing rollback-repeat test

Criterion 1 was quietly false until the last commit

HostIdentity::engine was the wasmtime version alone. So an engine_config() edit moved neither the activation identity nor, for 3 of 8 flags, deserialize's verdict — and max_wasm_stack decides whether a deeply nested file traps or extracts. Two indexes holding different content could claim one identity, which is exactly what this criterion forbids.

Fixed as one mechanism with both sides spelled from a single list (EngineFlags/ENGINE_FLAGS, no .. rest pattern, so the compiler is the test), baked into the artifact via module_version and carried into the digest. The mutation goes red on the reuse, not on a recomputed hash.

The audit that surfaced it had the premise wrong — it claimed a stale .cwasm was cached across processes, which does not happen (PackageSet holds a StagingDir whose Drop is remove_dir_all). Checking the false premise is what found the real defect one field over.

Criterion 7, and why the gate asks what it asks

A package refusing on a subset of files still produced a generation that replaced the last good one, dropping those files' facts on all three channels — because the refusal path writes a contribution row, so build gates that count contributions saw a healthy count, and dispatch_round returned Accepted regardless.

The gate now asks about the loss, not the refusal: does promoting delete facts the active generation is serving for that file? A threshold was rejected (arbitrary, and at any N>0 it licenses exactly the silent loss); "any refusal fails" was rejected too (one permanently-broken file would make every future activation unreachable). Asking about the consequence avoids both.

m0059 records it durably because Recovery::Revalidate re-runs the gates with no extraction at all — an in-memory count reads zero there.

The residuals, and what two of them turned out to be

  • #128 — a full disk was reported to operators as a malformed fact, pointing them at the package instead of the disk. Worse than filed: dispatch_round's entire error universe is IndexerError::Sqlite plus one contract violation, so for nearly every error it could see the label was a lie. The registry caught the fix itself.
  • #127 — cursors could span a promotion silently. The cursor's own shape is now the disclosure, so nothing was added to a reply body.
  • #129 — DAEMON_READERS set equality plus per-file floors. It went green on its first run with an empty exception list: the four registered files were already exactly right.
  • #130 — a test and three sentences. Its filing was wrong, and I wrote that framing: the ORDER BY id DESC → ASC change it named is structurally ungradable at SUPERSEDED_RETENTION_LIMIT = 1, where the two queries are identical. Recorded as a survivor with that reason rather than a test that would have looked like coverage.
  • Criterion 4's health-identity item was refuted — already shipped in d66552c.

Carried forward, not silently dropped

  • #123 — Response::Unclaimed collapses three causes (routes-to-nobody, vanished file, read failure) into one count that reads as the designed case. Same silent-loss family as #115; code-verified, not driven end to end, so deliberately not widened into that fix.
  • #134 — a tier-3 basename-stem reachability defect the cursor work uncovered. Not patched: the obvious guard costs real cross-crate recall.
  • The projection-triangle caveat on criterion 6, above.
## All eight criteria met. Closing on `01a478b`, CI run 584 green. Criterion 7 was the live defect and it was the serious one; the rest were already met or were met by the residual sweep. Walking them: | # | criterion | state | |---|---|---| | 1 | every interpretation change yields a new activation identity | **MET** — and it was **not** met until `01a478b`. See below. | | 2 | all old/new claim-domain files reconsidered | MET — `dirty_domain`, graded by the claim-domain suites | | 3 | normal queries observe no pending rows | MET — the reader epoch, 83 daemon sites gated | | 4 | promotion and rollback atomic across facts, resolutions, aggregates and disclosures | MET, with one real violation found and fixed in the sweep — `read_activation` re-read `state='active'` **thirteen statements after** the epoch gated the counts, breaking a rule `read_file_claim` states verbatim. Now pinned three-state. | | 5 | `kill -9` at every injected phase recovers without database deletion | MET — the crash matrix over all 12 durable transitions | | 6 | cold == activated == watcher-converged | MET as a projection triangle over five id-independent projections. **Stated honestly:** the *identity* half is excluded from comparison by design (`generation_equivalence.rs:113-121`), so this is equivalence of what the generations hold, not of what they are called. | | 7 | **failed/hanging packages cannot replace or corrupt the last good generation** | **MET** — #115 | | 8 | package removal leaves zero active contributions; rollback works while retention permits | MET — and #130 added the missing rollback-repeat test | ### Criterion 1 was quietly false until the last commit `HostIdentity::engine` was **the wasmtime version alone**. So an `engine_config()` edit moved neither the activation identity nor, for 3 of 8 flags, `deserialize`'s verdict — and `max_wasm_stack` decides whether a deeply nested file **traps or extracts**. Two indexes holding *different content* could claim one identity, which is exactly what this criterion forbids. Fixed as one mechanism with both sides spelled from a single list (`EngineFlags`/`ENGINE_FLAGS`, no `..` rest pattern, so the compiler is the test), baked into the artifact via `module_version` and carried into the digest. **The mutation goes red on the reuse, not on a recomputed hash.** The audit that surfaced it had the premise wrong — it claimed a stale `.cwasm` was cached across processes, which does not happen (`PackageSet` holds a `StagingDir` whose `Drop` is `remove_dir_all`). Checking the false premise is what found the real defect one field over. ### Criterion 7, and why the gate asks what it asks A package refusing on a *subset* of files still produced a generation that **replaced** the last good one, dropping those files' facts on all three channels — because the refusal path writes a contribution row, so build gates that count contributions saw a healthy count, and `dispatch_round` returned `Accepted` regardless. The gate now asks about the **loss, not the refusal**: does promoting delete facts the active generation is serving for that file? A threshold was rejected (arbitrary, and at any N>0 it licenses exactly the silent loss); "any refusal fails" was rejected too (one permanently-broken file would make every future activation unreachable). Asking about the consequence avoids both. `m0059` records it durably **because `Recovery::Revalidate` re-runs the gates with no extraction at all** — an in-memory count reads zero there. ### The residuals, and what two of them turned out to be - **#128** — a full disk was reported to operators as a **malformed fact**, pointing them at the package instead of the disk. Worse than filed: `dispatch_round`'s entire error universe is `IndexerError::Sqlite` plus one contract violation, so for nearly every error it could see the label was a lie. The registry caught the fix itself. - **#127** — cursors could span a promotion silently. The cursor's own shape is now the disclosure, so nothing was added to a reply body. - **#129** — `DAEMON_READERS` set equality plus per-file floors. It went green on its first run with an empty exception list: the four registered files were already exactly right. - **#130** — a test and three sentences. **Its filing was wrong**, and I wrote that framing: the `ORDER BY id DESC → ASC` change it named is *structurally ungradable* at `SUPERSEDED_RETENTION_LIMIT = 1`, where the two queries are identical. Recorded as a survivor with that reason rather than a test that would have looked like coverage. - Criterion 4's health-identity item was **refuted** — already shipped in `d66552c`. ### Carried forward, not silently dropped - **#123** — `Response::Unclaimed` collapses three causes (routes-to-nobody, vanished file, read failure) into one count that reads as the designed case. Same silent-loss family as #115; code-verified, not driven end to end, so deliberately not widened into that fix. - **#134** — a tier-3 basename-stem reachability defect the cursor work uncovered. Not patched: the obvious guard costs real cross-crate recall. - The projection-triangle caveat on criterion 6, above.
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#78
No description provided.