feat: automatic package update — apply silently ONLY when nothing about the authority changed, refuse otherwise #237

Closed
opened 2026-09-09 10:35:58 +02:00 by buildagent · 1 comment
Member

Operator decision, taken deliberately: auto-apply, refusing on any privilege increase. Depends on #236 for the fetch.

The tension this has to survive

This codebase's spine is "THE OPERATOR ANSWERS, NOT YOU". plugin_add elicits a confirmation no argument can supply; --yes requires --grant, described in the source as "the one clause separating a pre-filled answer from a delegation".

Automatic update inverts that: new third-party extractor code executes in the operator's sandbox, over their source, with no human act. That is a real delegation and the design has to earn it. The way it earns it is by being narrow: auto-apply is permitted only where nothing about the authority changed, and every other case stops and asks.

Apply automatically ONLY if all of these hold

  1. Same anchored publisher key. Not "a valid signature" — the same key, by fingerprint, as the one that signed the version currently approved. A different key is a different publisher however valid its signature.
  2. No capability beyond what is already granted. The new version's requested set must be a subset of the current grant. A superset is a privilege escalation and applying it silently would hand out authority the operator never gave.
  3. No new bridge, and no new [[displaces]]. Displacing a builtin is consent the operator gave for a specific claim set; a widened one is a new decision.
  4. Same package id. A different id is a different package, not an update.

Anything else: do not apply, record that an update is available and why it stopped, and surface it through plugin status, plugin doctor and an MCP disclosure. The reason must be specific — "requests derived_names, which you have not granted" — never "an update is available".

The clause I would argue about, and it is the operator's call

When I put the options I included "and does not change extraction_identity" among the auto-apply conditions. I now think that clause is too tight and would make the feature apply to almost nothing, and I would rather say so than build to a sentence I wrote carelessly.

extraction_identity changes whenever extraction behaviour changes — which is what most real updates are. Refusing on it limits auto-apply to re-graded fixtures and manifest comments. And it is a cost concern (a reindex — measured at 1,323 files / 87,528 symbols / 142,614 refs for TimeLine), not an authority concern, which is what conditions 1–4 are about.

Proposal: an extraction_identity change does NOT block auto-apply, but the reindex is measured before applying (approval::plan_domain already does this) and announced, and there is a configurable ceiling above which it stops and asks. Until that is decided, implement it as a blocking condition — the conservative reading of what was chosen — behind a clearly named constant so the decision has one place to live.

Configuration

Opt-in per package, never global-on. Something like, in the project's plugin config:

[update."de.h-dv.timeline"]
source     = "https://git.h-dv.de/h-dv/code-index/releases/download/latest/de.h-dv.timeline.cip"
auto_apply = true          # default false: check and notify only

An absent entry means check-and-notify. No package updates itself into a project that never asked.

What a fix must prove

  • A patch update meeting all four conditions applies unattended, and the resulting state is identical to the operator having run plugin add by hand.
  • A new version requesting one extra capability REFUSES, and the reason names the capability. Must go RED against a build that compares only the signature.
  • A new version signed by a different but validly anchored key REFUSES. This is the arm most likely to be got wrong: "the signature verifies" is not "the same publisher".
  • A new [[displaces]] entry REFUSES.
  • Anti-vacuity: a package with auto_apply = false is never applied, only reported — and a package with no update available produces no notification. Without both arms, "always refuse" and "always notify" pass.
  • A failed apply leaves the previously approved version active and the index intact. plugin rollback executes no package by construction; the auto path must not weaken that.
  • The check runs without holding the writer lock, and a check that cannot reach the source reports unreachable, never up to date.

That last one is this project's recurring defect in a new place: absent is not a measurement. A network failure must not render as "no update available".

#236 (the fetch this depends on), #233 (plugin enable fails on a project of only package-claimed files — an auto-apply path would hit it unattended).

Operator decision, taken deliberately: **auto-apply, refusing on any privilege increase.** Depends on #236 for the fetch. ## The tension this has to survive This codebase's spine is *"THE OPERATOR ANSWERS, NOT YOU"*. `plugin_add` elicits a confirmation no argument can supply; `--yes` **requires** `--grant`, described in the source as *"the one clause separating a pre-filled answer from a delegation"*. Automatic update inverts that: new third-party extractor code executes in the operator's sandbox, over their source, with no human act. That is a real delegation and the design has to earn it. The way it earns it is by being **narrow**: auto-apply is permitted only where nothing about the authority changed, and *every* other case stops and asks. ## Apply automatically ONLY if all of these hold 1. **Same anchored publisher key.** Not "a valid signature" — the *same* key, by fingerprint, as the one that signed the version currently approved. A different key is a different publisher however valid its signature. 2. **No capability beyond what is already granted.** The new version's requested set must be a subset of the current grant. A superset is a privilege escalation and applying it silently would hand out authority the operator never gave. 3. **No new bridge, and no new `[[displaces]]`.** Displacing a builtin is consent the operator gave for a specific claim set; a widened one is a new decision. 4. **Same package id.** A different id is a different package, not an update. Anything else: do not apply, record that an update is available and *why it stopped*, and surface it through `plugin status`, `plugin doctor` and an MCP disclosure. **The reason must be specific** — "requests `derived_names`, which you have not granted" — never "an update is available". ## The clause I would argue about, and it is the operator's call When I put the options I included *"and does not change `extraction_identity`"* among the auto-apply conditions. **I now think that clause is too tight and would make the feature apply to almost nothing**, and I would rather say so than build to a sentence I wrote carelessly. `extraction_identity` changes whenever extraction *behaviour* changes — which is what most real updates are. Refusing on it limits auto-apply to re-graded fixtures and manifest comments. And it is a **cost** concern (a reindex — measured at 1,323 files / 87,528 symbols / 142,614 refs for TimeLine), not an **authority** concern, which is what conditions 1–4 are about. Proposal: an `extraction_identity` change does NOT block auto-apply, but the reindex is **measured before applying** (`approval::plan_domain` already does this) and **announced**, and there is a configurable ceiling above which it stops and asks. Until that is decided, implement it as a blocking condition — the conservative reading of what was chosen — behind a clearly named constant so the decision has one place to live. ## Configuration Opt-in per package, never global-on. Something like, in the project's plugin config: ```toml [update."de.h-dv.timeline"] source = "https://git.h-dv.de/h-dv/code-index/releases/download/latest/de.h-dv.timeline.cip" auto_apply = true # default false: check and notify only ``` An absent entry means check-and-notify. No package updates itself into a project that never asked. ## What a fix must prove * A patch update meeting all four conditions applies unattended, and the resulting state is identical to the operator having run `plugin add` by hand. * **A new version requesting one extra capability REFUSES, and the reason names the capability.** Must go RED against a build that compares only the signature. * A new version signed by a *different but validly anchored* key REFUSES. This is the arm most likely to be got wrong: "the signature verifies" is not "the same publisher". * A new `[[displaces]]` entry REFUSES. * **Anti-vacuity**: a package with `auto_apply = false` is never applied, only reported — and a package with **no** update available produces no notification. Without both arms, "always refuse" and "always notify" pass. * A failed apply leaves the previously approved version active and the index intact. `plugin rollback` executes no package by construction; the auto path must not weaken that. * The check runs without holding the writer lock, and a check that cannot reach the source reports *unreachable*, never *up to date*. That last one is this project's recurring defect in a new place: **absent is not a measurement.** A network failure must not render as "no update available". ## Related #236 (the fetch this depends on), #233 (`plugin enable` fails on a project of only package-claimed files — an auto-apply path would hit it unattended).
Author
Member

Implemented on master at f75ee20. 26 mutations run, 25 RED, 1 survivor recorded as a survivor. Two operator decisions were needed and have been taken; both are below.

What shipped

code-index plugin update [--check] reads [update."<id>"] from .code-index.toml, resolves the digest that activates for that id (ApprovedPackageIds, so a #91-shadowed grant is never mistaken for the version in force), fetches through #236's plugin_fetch::fetch_package, asks the operator's own trust store who signed it, then evaluates. plugin status, plugin doctor and project_overview.package_updates render the record.

The four conditions live in code_index_indexer::update::assess_auto_apply (crates/indexer/src/update.rs) — the right place because crates/cli and crates/mcp-server both surface this and neither may depend on the other, every input is a value the host measured rather than a caller-supplied string, and it sits beside plan_grant, whose output the subset clause compares. What was checked and what gets written are the same call.

Conditions 2 and 3 collapse into one clause, which is the right shape: the grant an auto-apply would write is plan_grant(candidate, Requested) and must be a subset of the grant in force — capabilities, bridges and derived_names are the three things a grant carries. Displacement is separate because it is not in the grant. A fifth arm was added beyond the issue: a digest plugin disable withdrew is never auto-applied (#86 F1 — undecline is documented as never called by a machine, and apply_update does not call it).

EXTRACTION_IDENTITY_BLOCKS_AUTO_APPLY is at crates/indexer/src/update.rs:194. Its doc carries this issue's own reasoning verbatim — that it is a COST concern (the 1,323 files / 87,528 symbols / 142,614 refs measurement) rather than an authority one — states what flipping it changes, and names the test that reddens on the flip.

I re-ran the arm this issue flagged as "most likely to be got wrong" myself rather than trusting the report. Forcing the same-publisher clause to never fire:

a_different_but_validly_anchored_key_refuses ... FAILED
  a different publisher must refuse
test result: FAILED. 14 passed; 1 failed

Restored by cp, md5 verified, re-run 15 passed; 0 failed.

The survivor, reported as a survivor: m02 — making apply_update call next.undecline(...) — SURVIVED, because nothing is declined in that bench so the call is a no-op. It is recorded in the test's own doc, and the arm that actually kills the defect is assess_auto_apply's declined clause, graded RED by m16.


Decision 1 — the shadowing defect, and it is bigger than this issue

The lane found something this issue did not anticipate, and it is the most important thing in the change.

ApprovalRecord::set de-duplicates by digest, so after an auto-apply both grants stand, and by #91 the lexicographically lowest digest activates. A digest is a hash. So on any given pair that is a coin the bytes already flipped: roughly half of all auto-applies would leave the new version approved and shadowed, with the old one still extracting.

This is not an auto-update bug. Manual plugin add does the same thing — domain_between runs before Store::install, so ApprovedPackageIds::resolve cannot see the candidate yet and plugin add cannot even report it. A human meets the fact on their next plugin status. An unattended apply has no next command and no reader.

So this issue's acceptance criterion — "the resulting state is identical to the operator having run plugin add by hand" — is satisfiable and is the wrong target. Parity with a path that silently no-ops half the time buys a feature that silently no-ops half the time.

Operator decision: fix activation for both paths. The most-recently-approved digest activates, rather than the lexicographically-lowest one. That fixes manual plugin add identically instead of working around it in the auto path, and it means auto-update inherits correct behaviour rather than compensating for broken behaviour.

That changes #91's activation semantics globally, so it is its own change with its own measurement and is filed separately rather than folded in here. In the meantime the lane's update_active line stands: measured after the install and the save, it either names the new digest as live or carries package_id_already_active with its repair. The state is honest today; it becomes correct when the activation fix lands.

Decision 2 — where [update] is read from

The lane raised a real tension and was right to raise it. PluginRequest's own doc draws the line: languages.enabled is "a precedent for CONFIGURING from the repository. It is not a precedent for GRANTING AUTHORITY from the repository" — and the fact it names as decisive is that no third-party code enters the process. auto_apply changes that.

Operator decision: keep it in .code-index.toml, as this issue specifies. The four conditions bound it hard — a repository that flips that line cannot obtain a single capability, bridge, displacement or publisher the operator has not already granted by hand. What it can obtain is "run this publisher's newer signed code without asking again", for a publisher already trusted by fingerprint. That is the delegation intended, and it is shared by the team in one place.

The tension is written down at the field rather than resolved silently, which is the correct handling: a future reader should meet the argument, not the conclusion alone.


Two things to watch, neither blocking

project_overview headroom is now 3 tokens. The lane's first draft of the MCP block put it 57 tokens over its 4,050-token ceiling — the whole overage in one four-line constant on the arm that describes nearly every project ("no check has run"), which is exactly the shape PACKAGE_STORE_SEMANTICS's doc says must ride on HAVING A ROW. The ceiling was not moved. The constant was cut to one clause and the long form left on the arm that ships only when something notifies. Measured after: content 4,047, block ≈23 tokens, headroom 3 — down from the 30 that file documents. The next addition to project_overview will have to find bytes rather than trim its own.

A stale claim in PluginRequest's doc: it says a package whose bytes are not committed "needs a fetch story this project does not have: it is local-only, with no webserver and no network at verification time." #236 shipped that fetch and this change uses it. The refusal it describes is still correct for [[plugins]], so the text was left alone — but it now reads as a blocker that no longer exists.

Gates

fmt --check clean; clippy --workspace --all-targets -D warnings exit 0; RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --document-private-items exit 0; cargo test --workspace --no-fail-fast EXIT=0 across 330 test binaries, 0 failures; COSI_E2E_LEG=daemon cargo test -p code-index-mcp EXIT=0, 62 binaries. tests/corpus/baseline.json untouched.

Closing. The activation fix is filed as its own issue.

Implemented on `master` at `f75ee20`. **26 mutations run, 25 RED, 1 survivor recorded as a survivor.** Two operator decisions were needed and have been taken; both are below. ## What shipped `code-index plugin update [--check]` reads `[update."<id>"]` from `.code-index.toml`, resolves the digest that **activates** for that id (`ApprovedPackageIds`, so a #91-shadowed grant is never mistaken for the version in force), fetches through #236's `plugin_fetch::fetch_package`, asks the operator's **own trust store** who signed it, then evaluates. `plugin status`, `plugin doctor` and `project_overview.package_updates` render the record. **The four conditions live in `code_index_indexer::update::assess_auto_apply`** (`crates/indexer/src/update.rs`) — the right place because `crates/cli` and `crates/mcp-server` both surface this and neither may depend on the other, every input is a value the *host* measured rather than a caller-supplied string, and it sits beside `plan_grant`, whose output the subset clause compares. What was checked and what gets written are the same call. Conditions 2 and 3 collapse into **one clause**, which is the right shape: the grant an auto-apply would write is `plan_grant(candidate, Requested)` and must be a subset of the grant in force — capabilities, bridges and `derived_names` are the three things a grant carries. Displacement is separate because it is not in the grant. A fifth arm was added beyond the issue: **a digest `plugin disable` withdrew is never auto-applied** (#86 F1 — `undecline` is documented as never called by a machine, and `apply_update` does not call it). `EXTRACTION_IDENTITY_BLOCKS_AUTO_APPLY` is at `crates/indexer/src/update.rs:194`. Its doc carries this issue's own reasoning verbatim — that it is a COST concern (the 1,323 files / 87,528 symbols / 142,614 refs measurement) rather than an authority one — states what flipping it changes, and names the test that reddens on the flip. I re-ran the arm this issue flagged as *"most likely to be got wrong"* myself rather than trusting the report. Forcing the same-publisher clause to never fire: ``` a_different_but_validly_anchored_key_refuses ... FAILED a different publisher must refuse test result: FAILED. 14 passed; 1 failed ``` Restored by `cp`, md5 verified, re-run `15 passed; 0 failed`. **The survivor, reported as a survivor:** m02 — making `apply_update` call `next.undecline(...)` — SURVIVED, because nothing is declined in that bench so the call is a no-op. It is recorded in the test's own doc, and the arm that actually kills the defect is `assess_auto_apply`'s `declined` clause, graded RED by m16. --- ## Decision 1 — the shadowing defect, and it is bigger than this issue The lane found something this issue did not anticipate, and it is the most important thing in the change. `ApprovalRecord::set` de-duplicates by **digest**, so after an auto-apply **both grants stand**, and by #91 the **lexicographically lowest digest activates**. A digest is a hash. So on any given pair that is a coin the bytes already flipped: **roughly half of all auto-applies would leave the new version approved and shadowed, with the old one still extracting.** This is *not* an auto-update bug. Manual `plugin add` does the same thing — `domain_between` runs before `Store::install`, so `ApprovedPackageIds::resolve` cannot see the candidate yet and `plugin add` cannot even report it. A human meets the fact on their next `plugin status`. An unattended apply has no next command and no reader. So this issue's acceptance criterion — *"the resulting state is identical to the operator having run `plugin add` by hand"* — **is satisfiable and is the wrong target.** Parity with a path that silently no-ops half the time buys a feature that silently no-ops half the time. **Operator decision: fix activation for both paths.** The most-recently-approved digest activates, rather than the lexicographically-lowest one. That fixes manual `plugin add` identically instead of working around it in the auto path, and it means auto-update inherits correct behaviour rather than compensating for broken behaviour. That changes #91's activation semantics globally, so it is **its own change with its own measurement** and is filed separately rather than folded in here. In the meantime the lane's `update_active` line stands: measured after the install and the save, it either names the new digest as live or carries `package_id_already_active` with its repair. The state is honest today; it becomes correct when the activation fix lands. ## Decision 2 — where `[update]` is read from The lane raised a real tension and was right to raise it. `PluginRequest`'s own doc draws the line: `languages.enabled` is *"a precedent for CONFIGURING from the repository. It is not a precedent for GRANTING AUTHORITY from the repository"* — and the fact it names as decisive is that no third-party code enters the process. `auto_apply` changes that. **Operator decision: keep it in `.code-index.toml`**, as this issue specifies. The four conditions bound it hard — a repository that flips that line cannot obtain a single capability, bridge, displacement or publisher the operator has not already granted by hand. What it can obtain is "run this publisher's newer signed code without asking again", for a publisher already trusted by fingerprint. That is the delegation intended, and it is shared by the team in one place. The tension is written down at the field rather than resolved silently, which is the correct handling: a future reader should meet the argument, not the conclusion alone. --- ## Two things to watch, neither blocking **`project_overview` headroom is now 3 tokens.** The lane's first draft of the MCP block put it **57 tokens over** its 4,050-token ceiling — the whole overage in one four-line constant on the arm that describes *nearly every project* ("no check has run"), which is exactly the shape `PACKAGE_STORE_SEMANTICS`'s doc says must ride on HAVING A ROW. **The ceiling was not moved.** The constant was cut to one clause and the long form left on the arm that ships only when something notifies. Measured after: content **4,047**, block ≈23 tokens, headroom **3** — down from the 30 that file documents. The next addition to `project_overview` will have to find bytes rather than trim its own. **A stale claim in `PluginRequest`'s doc:** it says a package whose bytes are not committed *"needs a fetch story this project does not have: it is local-only, with no webserver and no network at verification time."* #236 shipped that fetch and this change uses it. The refusal it describes is still correct for `[[plugins]]`, so the text was left alone — but it now reads as a blocker that no longer exists. ## Gates `fmt --check` clean; `clippy --workspace --all-targets -D warnings` exit 0; `RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --document-private-items` exit 0; `cargo test --workspace --no-fail-fast` **EXIT=0** across 330 test binaries, 0 failures; `COSI_E2E_LEG=daemon cargo test -p code-index-mcp` **EXIT=0**, 62 binaries. `tests/corpus/baseline.json` untouched. Closing. The activation fix is filed as its own issue.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

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