Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to 06d1abe. Security Overview
Detected Code Changes
|
This was referenced Aug 19, 2026
sea-snake
force-pushed
the
feat/track-default-accounts
branch
from
August 19, 2026 06:28
5f19465 to
d6098a9
Compare
sea-snake
force-pushed
the
feat/track-default-accounts
branch
2 times, most recently
from
August 19, 2026 08:17
79f9469 to
e87a78c
Compare
sea-snake
force-pushed
the
feat/track-default-accounts
branch
from
August 19, 2026 08:37
e87a78c to
55b91fc
Compare
sea-snake
force-pushed
the
feat/track-default-accounts
branch
from
August 20, 2026 10:57
55b91fc to
801d29c
Compare
sea-snake
force-pushed
the
feat/track-default-accounts
branch
from
August 20, 2026 17:24
801d29c to
331c21c
Compare
The canister records only materialized accounts today, so the common case — a sign-in at an origin where the anchor holds no account — persists nothing. Tracking it is one change with the two things that bound it, because none of the three stands alone: tracking makes every sign-in at a new origin mint an application row from an operation with no per-anchor cap, eviction is what makes an application's reference count fall, and reaping is dead code without it. Implements docs/ongoing/tracked-default-accounts.md §5-§8 (D5-D12, D14, D15, D18).
sea-snake
force-pushed
the
feat/track-default-accounts
branch
from
August 22, 2026 18:42
331c21c to
c850d43
Compare
`set_account_last_used` stamped a field, created a reference-list row, allocated an application number and evicted idle rows, behind a name that promised only the first. It also returned `Result<Option<()>, _>` whose `Option` no caller read and which production could never see as `None`. It becomes `record_account_use`, returning `Result<(), _>`, and reads as the two steps it is: make sure the default has a reference at this origin, then stamp the reference. The first step is the existing `ensure_account_reference_list`, and only a default takes it, because a named account with no reference is one that was removed and must stay removed. `with_account_mut` loses its two near-identical arms, and no longer writes a reference list and an account record back unchanged when no reference matched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rack-default-accounts # Conflicts: # src/internet_identity/src/storage.rs
`remove_reference_list` discarded the `Result` that `apply_reference_counter_deltas` now returns, so a failed counter write was dropped on the path that retires a row. The tests do not see it because an unused `Result` is a warning until CI runs clippy with `-D warnings`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MRmarioruci
reviewed
Aug 31, 2026
The reaping branch and the refusing delta meet here: the row is only retired when no anchor references it, not when a drifted counter was clamped to zero.
Three paths minted rows before the check that could refuse. The worst is set_default_account_for_origin: the application row went in first and the named account was validated after, so every NoSuchAccount left a row behind — and nothing reaps it, because a row is only retired when a reference list is written and that path never writes one. The origin is the caller's to choose, so it was one stranded row per call, unbounded. create_default_account did the same across more state: an account number that is never reissued, the stored account, the application row and its config, all committed before the MissingAccount that its own comment predicts. create_additional_account only leaks on a storage fault, but the allocation moves ahead of the writes so all three read the same way.
Asserted on the application-count metric, which is what a stranded row shows up in — one per call, at an origin the caller picks.
The new test was added between an existing #[test] and its function, which duplicated the attribute on one and left the other unregistered — it had stopped running.
…rack-default-accounts # Conflicts: # src/internet_identity/src/account_management.rs # src/internet_identity/src/storage.rs # src/internet_identity/tests/integration/accounts.rs
…rack-default-accounts # Conflicts: # src/internet_identity/src/storage.rs # src/internet_identity/src/storage/tests.rs
"Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…rack-default-accounts # Conflicts: # src/internet_identity/src/storage/tests.rs
"Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…rack-default-accounts # Conflicts: # src/internet_identity/src/storage.rs # src/internet_identity/src/storage/tests.rs
…rack-default-accounts
…rack-default-accounts
Eviction rode on a second call made once the first had returned, so a write that pushed an identity over the cap was two atomic units — and an `Err` from the second committed the first. It also only rode on writes that *created* a list, so an identity could drift well past the cap through writes that did not. Victims are selected in validate now, against the state the write is about to leave behind rather than the state read back afterwards: the counters plus this call's deltas, with every origin the write touches excluded so a list it is in the middle of changing is never also a victim of it. The removals go in the same batch. `write_tracked_default`, `evict_idle_tracked_defaults` and `tracked_default_account_upper_bound` go with it — a caller no longer has to know the cap exists. Two tests had to plant their over-cap state rather than write it, because the write path can no longer produce one. That is the cap holding rather than a test getting harder: the shape they now describe is what an upgrade leaves behind from before it existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…ccount-empty-list-is-not-default # Conflicts: # src/internet_identity/src/account_management.rs
…rack-default-accounts
…rack-default-accounts # Conflicts: # src/internet_identity/src/account_management.rs
MRmarioruci
reviewed
Sep 7, 2026
MRmarioruci
reviewed
Sep 7, 2026
…rack-default-accounts
…rack-default-accounts
MRmarioruci
reviewed
Sep 7, 2026
… list The default account at an origin is a value derived from that origin's account reference list, and it was the last such value maintained outside this write. Validation built the list and then, lines later, took the caller's config exactly as given without ever relating the two. Two directions, one repair. A caller changing the default supplies a config; a write that removes what the default named supplies none, which means "leave it alone" — and leaving it alone must not mean leaving it naming an account that has gone. So the target is taken from the caller's config where there is one and from the stored config otherwise, and where the list being written does not hold it the default moves to the first reference that list still has. Repaired rather than refused: refusing would mean a write that drops a reference is rejected unless its caller also remembered to move the default, which is the hand-maintenance this layer exists to remove. The same shape as the browser cap dropping the sessions of a browser that falls out of it. The repair is settled before the decision about whether a list is written, which reads whether a config was supplied — settled after, a write carrying only a config the repair then dropped would still materialise a list for nothing. `set_default_account` stops building a config and moves one field of the stored one instead, as does the arm that derives a default from a freshly named account. Building one decides every field it leaves out, which is wrong the day the config holds a second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…e cap sits Three answers to review questions that the code had but never said. `ReferenceListDeltas::removing` becomes `retiring`. Its own doc comment already said retiring, and retire is this file's word for it everywhere else — an application no counter will ever retire, retirement running off a write to account state, a list retired when a live tracked default is all it holds. Only the name said otherwise, and "removing" reads as a process without a subject. `validate_removal` said when a list may be removed and never what goes with it. Four things do, and one of them is visible to a person: evicting an idle origin's list signs that origin's sessions out. `MAX_EVICTABLE_DEFAULT_ACCOUNTS` called itself a per-anchor cap, which invited the question of why the count settles elsewhere. It is where eviction triggers; the pass trims to the watermark below, and the origins the triggering write is touching are not candidates for it, so the count comes to rest a little above the watermark and not at either number. The test that asserts exactly that had the arithmetic in a bare `- 1`, which is now spelled out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…rack-default-accounts
…rack-default-accounts
…rack-default-accounts
…he list Four things this write got wrong about tracked defaults. Eviction triggered at the watermark rather than at the cap, so it began fifty lists early. The bound before it cannot stand in for the rule: it comes from counters, which know how many references and accounts an identity has and nothing about how they are spread across lists, so it counts every numberless reference including those in lists that also hold named accounts, which are never evictable. That makes it an upper bound, which is all it needs to be to keep the scan off the sign-in path — and it is why the rule itself belongs where the lists are. Evicting early is not free: an evicted list takes that origin's sessions with it, so an identity under the cap was being signed out of apps for nothing. Repairing a default that named an account the list no longer holds took whatever was first, which took the list's order for the rule. A write is stored in the order it was given, so a list whose numberless reference was not first would move the default onto a named account while the tracked default was still there to fall back to. The tracked default is now found wherever it sits, and position decides only when there is none. Comparing a write against what is stored cloned the stored list to do it. It only needs to borrow, and to build the derived default in the one branch that has no stored list to compare against. This is every sign-in, on a list that can hold five hundred references, each owning its sessions. `tombstones` becomes `stored_tombstones`, beside `stored_accounts` and `stored_account_references`, and its doc leads with what it counts rather than with why. The name on the wire is `#[n(3)]` and does not move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…ction Eviction now starts at the cap rather than at the watermark, and the origin a write is touching is never a candidate for its own eviction — so signing in at exactly the cap leaves one short of triggering, and nothing is reclaimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…rack-default-accounts
MRmarioruci
approved these changes
Sep 8, 2026
…rack-default-accounts
…rack-default-accounts
sea-snake
added a commit
that referenced
this pull request
Sep 10, 2026
Design: #4224. Overview: #4230. "This browser used this app 3 minutes ago" against "5 weeks ago" is what makes a session list worth reading, and what lets someone spot a session they do not recognise **still being used** rather than merely still existing. **Every refresh stamps**, and three consumers make coarsening it unattractive: the session cap orders live sessions on this field, so inside a coarsening interval every session would look equally idle; the browser registry cap orders on the same signal, where an hour is long enough to drop a browser in use; and a user-facing list that can be an hour stale does not answer the question it exists to answer. **What the stamps cost, stated separately, because they are not the same write.** `last_refreshed_ns` and the reference's `last_used` ride free: the list blob is rewritten either way, so they change bytes already in flight. The browser registry's `last_used` does not — `stamp_browser_use` is a `read` plus a `write` of the whole anchor, which serialises every passkey, OpenID credential, recovery key and verified email, pre-checks the email-recovery binding against its reverse index, and re-syncs five reverse indices by diffing vectors, all to move one `u64`. So this PR adds **one anchor read-modify-write per five minutes of app use, per live session**, where before there was one per sign-in. That is accepted here rather than argued away: the registry cap orders on this field, and a browser in use dropping out of the list is worse than the write. The coarsening argument above is about `last_refreshed_ns` and the user-facing list, and does not apply to the device field. The same write carries three stamps: | Field | Lives on | Drives | | ----- | -------- | ------ | | `last_used` | the account reference | account eviction (#4235) | | `last_refreshed` | the session record | the session cap (#4267) and the user-facing list | | `last_used` | the device record | the registry cap (#4242) and the settings list | Stamping the device is the only reason refresh touches the anchor: it authenticates by session chain and never runs `check_authorization`. What that write buys is a use signal a sign-in stamp cannot give the browser list. Stable writes scale with `1/T` alongside the calls, so lowering the app-delegation TTL multiplies both. Tests: `session_refresh_stamp_tests` (8), including a stamp for a session that is gone being refused rather than reporting success, and a refresh advancing the device's `last_used` while leaving its enrolment timestamp alone. PocketIC drives two refreshes a minute apart and reads the device back off `identity_info`. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design: #4222. Overview: #4230.
Signing in with an identity's default account at an origin it has never used records nothing — no timestamp, no reference list, no application list. That is the common case, so the canister only knows about accounts a user has explicitly named. Nothing is ever removed either, so applications would accumulate for the life of the canister.
This PR makes first use of the default account a stored fact, and bounds what that costs.
Tracking is one deleted guard
The base PR refuses to store a reference list whose only content is the derived default: absence already says an identity has its default at an origin, so a list repeating that stores nothing. Recording use is what changes that — a timestamp has nowhere else to live.
So tracking is the removal of that refusal from
write_account_state, and recording a use is the sameread_account/ setlast_used/write_accountevery other write goes through. A tracked default is an existing shape,AccountReference { account_number: None, last_used: Some(t) }, so nothing new is stored and a named account still cannot be created from nothing. A default that was given away is not recreated: a tombstone stays a tombstone.Why tracking, eviction and removal are one PR
They cannot be deployed separately, and this was measured rather than assumed: with tracking alone, one anchor signing in at 2,000 origins creates 2,000 reference lists and 2,000 application lists with no cap and no reclamation. Tracking mints an application list on an operation with no per-anchor cap; eviction is what bounds that and the only thing that makes a reference count fall; removal is dead code without eviction, since a count that never falls never reaches zero. A prefix of the three grows stable memory without bound on an attacker-drivable path.
Eviction
A cap of 500 lists holding nothing but a tracked default, screened by the cheap upper bound
stored_account_references - stored_accountswith the scan as the authority. Sign-in evicts rather than failing, in bounded batches — and where the screen fires but the scan finds no candidate, which an identity holding 500 lists that each pair a default with a named account can do, it proceeds having evicted nothing.Eviction is part of the write. Victims are chosen in validate, against the state the write is about to leave behind — the counters plus this call's deltas, with every origin the write touches excluded — and the removals go in the same batch. It used to hang off a second call made once the first had returned, which made a write that pushed an identity over the cap two atomic units: on the IC an
Errfrom the second commits the first, so a list could go while its sessions stayed.It takes the whole list and its config list, never leaves an empty list behind, and spares a default while a named account shares its list. The list the caller just wrote is excluded, since every message in a round reads the same
time()and would otherwise tie. Non-destructive: it drops a timestamp, and the account returns at the same principal on next use.Removal
The predicate is the existing
StorableApplication.stored_account_references, an accurate live count today because every insert is counted and nothing has ever been removed, so it only needed decrementing — which #4232 made a property of one function. No new field, no migration. Reaching zero removes the application list, and the origin-index entry when it still points at that number.A removal reads the stored list rather than the normalising one: an absent list must not look prunable, or its counters would be decremented for a list that never existed.
Tests
default_account_tracking_tests(8),tracked_default_eviction_tests(14),application_removal_tests(9). PocketIC drives 500 real sign-ins and asserts an evicted origin returns at the sameuser_key.