Skip to content

feat(rust): custom_value_endpoints form for export_encrypted_maps_canister! - #424

Merged
marc0olo merged 4 commits into
mainfrom
feat/em-macro-exclude
Jul 30, 2026
Merged

feat(rust): custom_value_endpoints form for export_encrypted_maps_canister!#424
marc0olo merged 4 commits into
mainfrom
feat/em-macro-exclude

Conversation

@marc0olo

@marc0olo marc0olo commented Jul 29, 2026

Copy link
Copy Markdown
Member

Implements the Rust half of #423.

What changes

export_encrypted_maps_canister! gains a second form:

// Full canister (unchanged — the 95% case):
ic_vetkeys::export_encrypted_maps_canister!("app", [memory(0), memory(1), memory(2), memory(3)]);

// New: control-plane + state + lifecycle + accessors, but NO value endpoints:
ic_vetkeys::export_encrypted_maps_canister!("app", [memory(0), memory(1), memory(2), memory(3)], custom_value_endpoints);

The custom_value_endpoints form generates the 8 control-plane endpoints, the stable state, #[init]/#[post_upgrade], and two accessors — but omits the 7 value read/write endpoints. Both forms now emit:

fn with_encrypted_maps<R>(f: impl FnOnce(&EncryptedMaps<AccessRights>) -> R) -> R;
fn with_encrypted_maps_mut<R>(f: impl FnOnce(&mut EncryptedMaps<AccessRights>) -> R) -> R;

Why

The generators are all-or-nothing today, so the common "wrap-and-extend" pattern — a canister that stores something linked to each value (e.g. a metadata row per entry) and must own the value endpoints to keep the two stores consistent — can't use the macro at all and hand-writes ~200 lines. custom_value_endpoints lets it generate the safe control-plane passthroughs and write only its custom value endpoints, reusing the library's vetKD/crypto/ACL via the accessors.

Coarse (whole value data-plane) instead of a fine-grained exclude: [list], deliberately — it's one unmissable decision rather than a list you can under-specify. (The exclude list in #423's own body proved the trap: it listed 6 names and omitted get_all_accessible_encrypted_maps, which returns raw values via EncryptedMapData.keyvals — a leak.) It's also forward-safe: a value endpoint added to the library later is hidden from adopters automatically, whereas a baked-in exclude list would silently re-expose it. Full rationale and the alternatives considered are in the #423 thread.

How it simplifies adoption (password_manager_with_metadata)

Today that example (~250 lines) forgoes the generator and hand-writes 11 endpoints; ~120–130 of those lines are the 8 passthroughs + init/post_upgrade/state setup that the generator already produces. With custom_value_endpoints those collapse to a 3-line macro call and only the 3 genuine *_with_metadata endpoints stay hand-written — roughly halving the file, keeping the passthroughs frontend-compatible by construction, and never emitting the raw value mutators (so the value↔metadata invariant can't be bypassed at the boundary).

Which endpoints, and the rationale

  • Omitted (value data-plane, 7): writes insert_encrypted_value, remove_encrypted_value, remove_map_values; reads get_encrypted_value, get_encrypted_values_for_map, get_all_accessible_encrypted_values, get_all_accessible_encrypted_maps.
  • Only the 3 writes can break an adopter invariant (correctness). The 4 reads are dropped for API-surface hygiene (no metadata-less value view beside the wrapped one). Documented in the macro rustdoc.
  • Kept (control-plane, 8): none read or write values — set_user_rights/remove_user touch only the ACL, no value cascade — so they're always safe to emit.

Interface / release impact

  • The full form is unchanged. Endpoints are factored into inner macros, but export_candid! emits methods alphabetically, so the generated .did is identical — verified by diffing the extracted candid (method set identical) and by interface-sync regenerating declarations from the committed .did (untouched). No frontend files or .did change; no frontend release.
  • Additive/minor change. Recorded under an Unreleased changelog heading; the version number is assigned at release time (no Cargo.toml bump in this PR).

Docs

  • Macro rustdoc documents both forms, the omitted set with the write=correctness / read=hygiene rationale, the accessors, a metadata example, and the escape hatch if a write-only-wrap-keeping-reads need appears.
  • Crate README (the docs.rs landing page) gains a "Ready-made EncryptedMaps canister" section pointing to the macro and both forms — it previously didn't mention the generator at all.
  • Reference-canister READMEs: the new custom canister gets its own README, cross-linked from the full reference canister's README.

Implementation & verification

  • Factored the macro into __export_encrypted_maps_{common,control_plane_endpoints,value_endpoints}! helpers composed by the two public arms.
  • Added a reference canister ic-vetkeys-encrypted-maps-custom-canister that dogfoods the new form + accessors; wired into the CI wasm build.
  • Verified: full-form .did identical; encrypted_maps reference tests 19/19; doctests pass and cargo doc -D warnings clean; cargo fmt --check and CI-style cargo clippy -- -Dwarnings clean; custom canister builds host + wasm.

Motoko gets the mirrored layered mixin in a separate PR.

@marc0olo
marc0olo requested review from a team as code owners July 29, 2026 20:34
…ister!

Add a second form of the macro for the wrap-and-extend pattern: a canister that
keeps state linked to each encrypted value (e.g. a metadata row per entry) and
must own the value read/write endpoints to keep that state consistent.

  export_encrypted_maps_canister!("app", [m0,m1,m2,m3], custom_value_endpoints);

This generates the control-plane endpoints, the stable state, the
#[init]/#[post_upgrade] hooks, and with_encrypted_maps/_mut accessors, but omits
the 7 value read/write endpoints. Adopters write their own value endpoints and
reuse the library's vetKD/crypto/access-control via the accessors.

Coarse (whole value data-plane) rather than a fine-grained exclude list, on
purpose: it is one unmissable decision instead of a list you can under-specify
(the exclude list proposed in the issue itself omitted a value-reader), and any
value endpoint added later is hidden from adopters automatically. See #423.

The full form is unchanged; its generated Candid is identical (endpoints are
factored into inner macros, but export_candid! sorts methods, so the .did and
interface-sync are unaffected). Backend-only: no frontend files or .did change.

- Factor the macro into __export_encrypted_maps_{common,control_plane_endpoints,
  value_endpoints}! helpers composed by the two public arms.
- Emit with_encrypted_maps/with_encrypted_maps_mut accessors in both forms.
- Add a reference canister (ic-vetkeys-encrypted-maps-custom-canister) that
  dogfoods the new form; wire it into the CI wasm build.
- Document the form, the omitted set, the write=correctness / read=hygiene
  rationale, and the accessors in the macro rustdoc.
- Record the change under an Unreleased changelog heading (no version bump yet).

Refs #423.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marc0olo
marc0olo force-pushed the feat/em-macro-exclude branch from 34cc483 to bb57cf0 Compare July 29, 2026 20:35
- Add a 'Ready-made EncryptedMaps canister' section to the ic-vetkeys crate
  README (the docs.rs landing page) covering both the full and
  custom_value_endpoints forms of export_encrypted_maps_canister! — the macro
  was previously not mentioned there at all. Example fenced as `ignore` since
  the README is included as crate docs (doctests).
- Add a README to the new custom reference canister and cross-link it from the
  full reference canister's README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in “control-plane only” form to the Rust ic_vetkeys::export_encrypted_maps_canister! macro to support wrap-and-extend canisters that must own their value endpoints (e.g., to maintain linked side-state such as per-entry metadata), while keeping the existing full-canister form unchanged.

Changes:

  • Extend export_encrypted_maps_canister! with a custom_value_endpoints form and add with_encrypted_maps / with_encrypted_maps_mut accessors.
  • Factor macro expansion into shared/common + control-plane endpoints + value endpoints helper macros.
  • Add a reference “custom canister” crate and wire it into the workspace and CI wasm build; update docs/changelog accordingly.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Cargo.toml Adds the new custom reference canister crate to the workspace members.
Cargo.lock Records dependency graph updates for the new canister package.
backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs Implements the new macro form, factors endpoint generation, and documents the two forms + accessors.
backend/rs/ic_vetkeys/README.md Adds a “Ready-made EncryptedMaps canister” section referencing the macro and new form.
backend/rs/ic_vetkeys/CHANGELOG.md Notes the additive macro form under “Unreleased”.
backend/rs/canisters/ic_vetkeys_encrypted_maps_custom_canister/src/lib.rs New reference canister exercising the custom_value_endpoints form with custom value endpoints.
backend/rs/canisters/ic_vetkeys_encrypted_maps_custom_canister/README.md Documentation for the new custom reference canister.
backend/rs/canisters/ic_vetkeys_encrypted_maps_custom_canister/Cargo.toml New crate manifest for the custom reference canister.
backend/rs/canisters/ic_vetkeys_encrypted_maps_canister/README.md Cross-links to the new custom canister and macro form for wrap-and-extend use cases.
.github/workflows/backend-rust.yml Builds the new custom canister in CI for wasm32 target.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs Outdated
Comment thread backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs
Comment thread backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs
- Rustdoc: the macro now also defines with_encrypted_maps/_mut (and the
  endpoint fns) as items in the invoking module; correct the 'does not bind any
  common names' claim to be precise about types vs. these items.
- Accessors: make with_encrypted_maps/_mut pub(crate) so they are reachable from
  submodules of the invoking crate, and replace unwrap() with expect(...) for a
  clear trap message if ever called before init.

No interface/.did change (accessors are helper fns, not endpoints).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs:15

  • The docstring says the custom_value_endpoints form provides a single accessor, but the macro emits two functions (with_encrypted_maps and with_encrypted_maps_mut). Update wording to avoid confusion.
//!   hooks and a [`with_encrypted_maps`]/[`with_encrypted_maps_mut`] accessor,

@mraszyk mraszyk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about not injecting #[init]/#[post_upgrade], but exposing functions that could be invoked from #[init]/#[post_upgrade] so that people could define their own #[init]/#[post_upgrade]?

Comment thread backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs
Comment thread backend/rs/ic_vetkeys/src/encrypted_maps/canister.rs
- Add parameter-semantics comments to the custom reference canister's endpoints.
- Trim the duplicated custom_value_endpoints rustdoc example to a minimal
  invocation snippet and point to the compile-tested reference canister +
  password_manager_with_metadata for the full pattern.

The init/post_upgrade injection question is left open pending more opinions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marc0olo

Copy link
Copy Markdown
Member Author

On the #[init]/#[post_upgrade] suggestion (@mraszyk): it's a fair composability point, so rather than decide unilaterally we'd like a couple more opinions before merging. One data point in the meantime: the motivating password_manager_with_metadata would get simpler under the current injected custom form — its #[init]/#[post_upgrade] only call init_encrypted_maps(key_name) and keep a KEY_NAME cell to feed the key back on upgrade, but the injected #[post_upgrade] already re-attaches by loading the key from EncryptedMaps' own config cell, and the metadata store self-initializes in its thread-local. So it would drop both hooks and the KEY_NAME cell entirely.

Exposing setup fns instead would help adopters who need custom init args or post-upgrade migration logic (none in the current use case), at the cost of more lifecycle boilerplate and reintroducing the 'forgot #[post_upgrade] → traps after upgrade' footgun we fixed in #421. Leaving it open for input; the other review comments are addressed.

@marc0olo
marc0olo merged commit 4360c43 into main Jul 30, 2026
15 checks passed
@marc0olo
marc0olo deleted the feat/em-macro-exclude branch July 30, 2026 21:29
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.

4 participants