Skip to content

feat(be): track the default account on first use at an origin - #4235

Merged
sea-snake merged 43 commits into
fix/read-account-empty-list-is-not-defaultfrom
feat/track-default-accounts
Sep 8, 2026
Merged

sea-snake merged 43 commits into
fix/read-account-empty-list-is-not-defaultfrom
feat/track-default-accounts

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 same read_account / set last_used / write_account every 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_accounts with 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 Err from 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 same user_key.

@sea-snake
sea-snake requested a review from a team as a code owner August 19, 2026 00:05
@zeropath-ai

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 06d1abe.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/internet_identity/src/storage.rs
initiate eviction logic for tracked defaults; introduce MAX_EVICTABLE_DEFAULT_ACCOUNTS, watermark, and per-call eviction controls
add evictable_after method, and integrate eviction during account reference write processing
► src/internet_identity/src/storage/storable/application.rs
extend StorableApplication with stored_tombstones field and accompanying tests for tombstone behavior
► src/internet_identity/src/storage/storable/account_reference.rs
add Debug derive to StorableAccountReference
► src/internet_identity/src/storage/storable/account_reference_list.rs
add Debug derive to StorableAccountReferenceList
► src/internet_identity/src/storage/tests.rs
add test scaffolding for remove_at and held_references helpers
► src/internet_identity/src/storage/account/tests.rs
set stored_tombstones default in test fixture
Bug Fix ► None indicated
Configuration changes ► None indicated
Refactor ► src/internet_identity/src/storage.rs
adjust return type of account_reference_list_write flow to (Vec, Option)
► src/internet_identity/src/storage/storable/application.rs
add stored_tombstones field with CBOR annotation and default
► src/internet_identity/src/storage/storable/account_reference.rs
add Debug derive for better test diagnostics
Other ► src/internet_identity/src/account_management.rs
update comments to reflect new eviction and default handling semantics

Copilot AI lite review requested due to automatic review settings August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/track-default-accounts branch from 5f19465 to d6098a9 Compare August 19, 2026 06:28

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sea-snake
sea-snake force-pushed the feat/track-default-accounts branch 2 times, most recently from 79f9469 to e87a78c Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/track-default-accounts branch from e87a78c to 55b91fc Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/track-default-accounts branch from 55b91fc to 801d29c Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/track-default-accounts branch from 801d29c to 331c21c Compare August 20, 2026 17:24
@sea-snake sea-snake added the feature:tracked-default-accounts Design: tracked default accounts, application removal, principal lookup label Aug 22, 2026
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 and others added 3 commits August 25, 2026 15:49
`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>
Comment thread src/internet_identity/src/account_management.rs Outdated
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
sea-snake and others added 11 commits September 6, 2026 01:45
…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
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

# Conflicts:
#	src/internet_identity/src/account_management.rs
Comment thread src/internet_identity/src/storage.rs Outdated
Comment thread src/internet_identity/src/storage.rs
Comment thread src/internet_identity/src/storage/storable/application.rs Outdated
sea-snake and others added 8 commits September 7, 2026 16:10
… 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
…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
@sea-snake
sea-snake merged commit 818f843 into main Sep 8, 2026
43 checks passed
@sea-snake
sea-snake deleted the feat/track-default-accounts branch September 8, 2026 13:10
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:tracked-default-accounts Design: tracked default accounts, application removal, principal lookup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants