feat: automatic package update — apply silently ONLY when nothing about the authority changed, refuse otherwise #237
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#237
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_addelicits a confirmation no argument can supply;--yesrequires--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
[[displaces]]. Displacing a builtin is consent the operator gave for a specific claim set; a widened one is a new decision.Anything else: do not apply, record that an update is available and why it stopped, and surface it through
plugin status,plugin doctorand an MCP disclosure. The reason must be specific — "requestsderived_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_identitychanges 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_identitychange does NOT block auto-apply, but the reindex is measured before applying (approval::plan_domainalready 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:
An absent entry means check-and-notify. No package updates itself into a project that never asked.
What a fix must prove
plugin addby hand.[[displaces]]entry REFUSES.auto_apply = falseis never applied, only reported — and a package with no update available produces no notification. Without both arms, "always refuse" and "always notify" pass.plugin rollbackexecutes no package by construction; the auto path must not weaken that.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 enablefails on a project of only package-claimed files — an auto-apply path would hit it unattended).plugin add <url>— fetch a package over HTTP, with the digest pin mandatory #236Implemented on
masteratf75ee20. 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'splugin_fetch::fetch_package, asks the operator's own trust store who signed it, then evaluates.plugin status,plugin doctorandproject_overview.package_updatesrender the record.The four conditions live in
code_index_indexer::update::assess_auto_apply(crates/indexer/src/update.rs) — the right place becausecrates/cliandcrates/mcp-serverboth 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 besideplan_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 andderived_namesare 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 digestplugin disablewithdrew is never auto-applied (#86 F1 —undeclineis documented as never called by a machine, andapply_updatedoes not call it).EXTRACTION_IDENTITY_BLOCKS_AUTO_APPLYis atcrates/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:
Restored by
cp, md5 verified, re-run15 passed; 0 failed.The survivor, reported as a survivor: m02 — making
apply_updatecallnext.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 isassess_auto_apply'sdeclinedclause, 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::setde-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 adddoes the same thing —domain_betweenruns beforeStore::install, soApprovedPackageIds::resolvecannot see the candidate yet andplugin addcannot even report it. A human meets the fact on their nextplugin 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 addby 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 addidentically 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_activeline stands: measured after the install and the save, it either names the new digest as live or carriespackage_id_already_activewith its repair. The state is honest today; it becomes correct when the activation fix lands.Decision 2 — where
[update]is read fromThe lane raised a real tension and was right to raise it.
PluginRequest's own doc draws the line:languages.enabledis "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_applychanges 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_overviewheadroom 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 shapePACKAGE_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 toproject_overviewwill 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 --checkclean;clippy --workspace --all-targets -D warningsexit 0;RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --document-private-itemsexit 0;cargo test --workspace --no-fail-fastEXIT=0 across 330 test binaries, 0 failures;COSI_E2E_LEG=daemon cargo test -p code-index-mcpEXIT=0, 62 binaries.tests/corpus/baseline.jsonuntouched.Closing. The activation fix is filed as its own issue.
plugin updatecompares no version: an OLDER package auto-applies as an update (measured, 0.0.1 over 9.9.9) #255pluginsubcommand is documented —plugin updateshipped with ZERO operator documentation and every gate green #256plugin add <url>holds the id AND the source URL but offers no[update]stanza — the one moment "easy update" is free, and it is dropped #257