Skip to content

fix: keep a binding's stamped id when this run cannot resolve it - #749

Closed
raymondk wants to merge 1 commit into
rk/settings-syncfrom
rk/binding-env-vars
Closed

fix: keep a binding's stamped id when this run cannot resolve it#749
raymondk wants to merge 1 commit into
rk/settings-syncfrom
rk/binding-env-vars

Conversation

@raymondk

Copy link
Copy Markdown
Collaborator

Stacked on #748 — review that one first; this PR's diff against main includes it. Base changes to main once #748 merges.

Problem

set_env_vars_for_canister builds a canister's variable list as manifest variables + the bindings it could resolve, and update_settings replaces the whole list. A binding whose target had no id in the environment's store was dropped by a filter_map with no warning — so the variable a full deploy had stamped was deleted, and the canister read an empty value from then on.

An unresolved binding means "no id in this store", not "not wired": the referenced canister lies outside the run's scope, or was deployed from another project root — which is what a workspace member deployed on its own sees of its dependencies. resolve_targets catches one shape of this (a member-scoped deploy that names no canisters) and misses the rest, because naming canisters explicitly skips the guard.

Reported from the field as a consumer canister silently losing its provider id across per-service deploys, worked around by re-asserting the values with settings update --add-environment-variable after every one.

Fix

  • Carry the current value over for a binding this run cannot compute, so the write no longer deletes what it cannot recompute.
  • Warn, naming the canister that could not be resolved and the variable left alone.
  • Read the canister's status only when there is an unresolved binding, so the common path still writes without reading first.

Resolved bindings still overwrite, and a manifest-declared value still outranks a stamped one, so nothing else about the PUBLIC_CANISTER_ID:* namespace changes — a variable for a binding that no longer exists is still pruned.

Test

scoped_deploy_keeps_a_dependency_id_it_cannot_resolve deploys a workspace, drops the dependency's entry from the id store (the state a service deployed from its own root is in), then redeploys just the consumer by name.

Without the fix, settings show afterwards holds only PUBLIC_CANISTER_ID:app — the dependency variable is gone, which is the reported bug reproduced. With it, the id survives and the run explains what it could not resolve.

Checks

cargo test -p icp --lib (310), --test dependency_tests (7), --test canister_settings_tests (13), --test deploy_tests (24 of 25), cargo fmt, cargo clippy --all-targets. The one failure is deploy_cloud_engine::docker::test, which needs a Docker daemon that is not running locally; it fails the same way on main.

Not covered here

icp canister settings sync still never stamps PUBLIC_CANISTER_ID:* at all — bindings are computed only on the deploy path — so it can neither add a missing dependency id nor correct a stale one. Whether sync should also own that namespace is a design question worth settling separately.

🤖 Generated with Claude Code

`set_env_vars_for_canister` wrote the canister's manifest variables plus the
bindings it could resolve, and `update_settings` replaces the whole list. So
a binding whose target had no id in the environment's store was not merely
skipped — the variable a full deploy had stamped was deleted, and the
canister read an empty value from then on, with nothing said anywhere.

An unresolved binding means "no id in *this* store", not "not wired": the
referenced canister lies outside the run's scope, or was deployed from
another project root, which is what a workspace member deployed on its own
sees of its dependencies. The `resolve_targets` guard catches one shape of
this (a member-scoped deploy that names no canisters) and does not catch the
rest, because naming canisters explicitly skips it.

Carry the current value over for a binding this run cannot compute, and warn
with the canister that could not be resolved. Resolved bindings still
overwrite, and a variable the manifest declares still outranks a stamped
one, so nothing else about the namespace changes. The canister's status is
read only when there is an unresolved binding, so the common path still
writes without reading first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@raymondk
raymondk requested a review from a team as a code owner August 31, 2026 00:22
@raymondk

Copy link
Copy Markdown
Collaborator Author

Closing this one. The state it guards — the consumer's id present in a store where the dependency's id is absent — is much narrower than the description implied. In a well-formed workspace, root resolution climbs from the member to the workspace root, so a per-service deploy reads the root's store and the binding resolves. Reaching the unresolvable state needs the dependency to have been deleted, its store key to have changed, the environment to exclude it, or root resolution not to climb.

That makes this defensive rather than the fix for the reported issue, and the reported issue is better explained by #748. Reopening later is cheap if we decide the invariant is still worth enforcing — the branch rk/binding-env-vars keeps the work.

@raymondk raymondk closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant