Skip to content

docs: design for app sessions in the client library - #4279

Open
sea-snake wants to merge 85 commits into
docs/silent-reauth-redirectfrom
docs/client-app-sessions
Open

docs: design for app sessions in the client library#4279
sea-snake wants to merge 85 commits into
docs/silent-reauth-redirectfrom
docs/client-app-sessions

Conversation

@sea-snake

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

Copy link
Copy Markdown
Contributor

Stacked on #4228. Fourth design in the set, below the overview. Documentation only.

The canister side of revocable sessions is designed and built across #4224 and #4228, and nothing calls it. @icp-sdk/auth holds no session, mints no app delegations and revokes nothing at sign-out, so no application can reach the feature, and the guide for sharing a sign-in across sibling subdomains promises a sign-out propagation that no code performs.

Adds docs/ongoing/client-app-sessions.md and docs/ongoing/client-app-sessions-spec.md. The design puts the session inside AuthClient: signIn() acquires one, the identity handed to the application carries a five-minute app delegation that it replaces as it ages, and signOut() calls app_revoke_session before clearing local state. Sessions add no public type, option or method, so an application calls signIn() and getIdentity() exactly as it does today.

Read the refresh section hardest, and its two diagrams: a sequence showing a mint landing between two requests where nothing notices, and a flowchart in the spec covering every path that reaches a mint with the guard on each.

Refresh is armed by requests and scheduled for the moment it is needed, never run on a recurring timer. That is what keeps the pre-mint threshold at 15 seconds: a threshold that instead has to catch a passing request must be wide enough that one turns up inside it, and everything it discards is paid for permanently, since minting early throws away the rest of a delegation's life. An active session refreshes about every four and three quarter minutes against the canister's five-minute floor; a two-minute threshold would refresh every three and add two thirds again to the update calls and stable writes of every active session.

The decisions most likely to be wrong if left implicit:

  1. A recurring timer is wrong for a reason beyond cost. app_prepare_delegation stamps the session's last-refreshed time, which is what II's settings screen shows the user as use and what the session cap reclaims on. Refreshing on a schedule regardless of use turns that column into "has a tab open". The scheduled mint therefore re-checks activity at the moment it fires and cancels if there has been none.
  2. A delegation lasts min(5 minutes, what remains of the session), so refreshing on the delegation's remaining life alone mints without end against an almost expired session. A session with less than the block margin left is over.
  3. The identity refreshes itself; AuthClient does not hand out a fresh one per call. An application gives the identity to an agent once and the agent keeps the object, so a snapshot of one delegation would sign with it until it expired and no later call would reach the agent holding it.
  4. The account principal is not derivable from the session chain, which is rooted at the session's own key rather than the account's. The canister returns it as account_principal but the transport result carries only the chain, so it comes from the first mint's user_key and is stored beside the chain, which is what lets a reload answer getPrincipal() without minting.
  5. The cross-subdomain hint carries the session's expiry, not the app delegation's. CookieDelegationStorage derives its hint from whatever delegation it is handed, so a five-minute one would tell a sibling the session expires in five minutes and there is nothing to resume.
  6. NoMatchingSession is terminal and everything else is transient, in the background as well as the foreground. Signing a user out because the network dropped is worse than a call that failed.

Internet Identity is configured as two values, the authorize URL a ceremony renders at and the canister that mints, each defaulting to its mainnet value, with the agent's own options passed through. Deriving either from the other reads well until an application needs a deployment of its own: a URL's origin is not a promise about which canister answers there, and taking the canister id from the session chain would leave the library reading its own configuration out of a credential. The chain's targets keep their part as a check, refusing a chain that names another canister or none at all.

Also corrects #4228, which said the client side "already exists" in @icp-sdk/auth. It is specified there and not built.

The canister side of revocable sessions is built and no client uses it, so no
application can reach the feature. This is the design for holding the session
inside @icp-sdk/auth: an application keeps calling signIn() and getIdentity()
and receives an identity, while the library acquires a session, mints
five-minute app delegations from it, and revokes at sign-out.

Sessions stay out of the public API. Adds the design and the specification it
links to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake requested a review from a team as a code owner August 23, 2026 10:57
@zeropath-ai

zeropath-ai Bot commented Aug 23, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 5be869d.

Security Overview
Detected Code Changes
Change Type Relevant files
New File ► docs/ongoing/client-app-sessions-spec.md
New documentation/spec file added for client app sessions in the client library.

The design said a delegation is minted when a caller needs one, which hid the
part that decides both the cost and the feel: an agent holds the identity for
hours, so the identity refreshes from inside the per-request hook.

Refresh is driven by requests and never by a timer. A request served with life
still left starts a mint in the background; one arriving with almost none waits
for it; an idle app makes no calls. Both thresholds are derived from what they
cover rather than chosen, because minting early discards the rest of a
delegation's life: a 45-second pre-mint threshold refreshes an active session
about every four and a quarter minutes against a floor of five, where two
minutes would refresh every three and add two thirds again to the update calls
of every active session.

Records the reason a timer is wrong beyond its cost, that the last-refreshed
stamp is what II shows the user as use; the guard against minting without end
against an almost expired session, since a delegation lasts min(5 minutes, what
remains of the session); and that mint calls need no new configuration, taking
the canister from the session chain's targets and the host from the identity
provider's origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 23, 2026 11:19

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.

Pull request overview

Adds a design + requirements specification for implementing revocable app sessions inside @icp-sdk/auth’s AuthClient, including on-demand minting/refreshing of short-lived (5-minute) app delegations and canister-side revocation on signOut().

Changes:

  • Document the client-library architecture: session stored in AuthClient, app delegations minted/rotated from the session, and revocation on sign-out.
  • Specify refresh behavior driven by requests (no timers), including thresholds, concurrency (single in-flight mint), and failure classification.
  • Specify compatibility fallback to icrc34_delegation when ii_session_delegation is not available.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
docs/ongoing/client-app-sessions.md High-level design and rationale for sessions living inside AuthClient, including request-driven refresh and sign-out revocation.
docs/ongoing/client-app-sessions-spec.md Detailed requirements/spec for acquisition, minting/refresh, failure handling, hint semantics, and compatibility behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/ongoing/client-app-sessions-spec.md Outdated
Comment thread docs/ongoing/client-app-sessions.md Outdated
sea-snake and others added 23 commits August 23, 2026 13:24
Refreshing whenever a request arrived with little life left needed a threshold
wide enough for a request to turn up inside it, and everything a threshold
discards is paid for by every active session forever. Scheduling one mint for
the moment it is needed reduces the threshold to what covers a mint, so an
active session refreshes at nearly the five-minute floor rather than every four
and a quarter, and an application making a request every couple of minutes stops
stalling.

The schedule checks at fire time that the application is still active, without
which an application idle since seconds after its last request would refresh
anyway and stamp the session as used. Requests stay the guarantee, since
browsers throttle timers in hidden tabs and fire them late after a sleep.

Also records that the account principal is not derivable from the session chain,
which is rooted at the session's own key rather than the account's, so it is
taken from the first mint and stored beside the chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sequence showed getIdentity() calling the canister, which is not what
happens and would have an implementer minting once per call. It now shows the
mint as part of signing in, getIdentity() returning the same identity without a
call, and requests being signed from what that identity holds.

Adds the refresh sequence to the design, so the invisibility of a mint landing
between two requests is visible, and a flowchart to the spec covering every path
that reaches a mint with its guards: the block margin, the fire-time activity
check, the almost expired session, and what a failure does in the foreground
against the background.

Records why the identity refreshes itself rather than AuthClient handing out a
fresh one per call: an application gives the identity to an agent once and the
agent keeps the object, so a snapshot would sign with a dead delegation and no
later call would reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthClient is for Internet Identity, so signIn() asks for a session and expects
one. Carrying a second acquisition path, and inspecting advertised scopes to
choose between them, is overhead for a case the library does not serve. A
provider that cannot answer is a failed sign-in.

Recorded in out of scope so the negotiation is not added back on the assumption
it was overlooked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One graph covering request arrival, the scheduled fire, two further entry
points, the call itself and failure classification laid out as unreadable
spaghetti: four concerns and four converging entry points is more than the
layout can place.

Three small charts instead, one question each: what a request finds, what a
scheduled mint checks, and how a mint ends. The entry points that need no
decision, and the one-in-flight rule, are a sentence rather than nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard said the application had to have been active recently, which named no
signal and no window, so it could not be implemented or tested as written.

A scheduled mint now cancels unless the delegation it is replacing signed at
least one request. Signing is the only activity the library can observe, and the
replaced delegation's own lifetime is the window, since that delegation is what
the question is about. No constant, and nothing to tune: an application making a
request at least once per delegation lifetime refreshes for as long as that
holds, and one that stops refreshes once more and then lets the next lapse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check read as "was this delegation ever used", which suggests one request
keeps a session refreshing for its whole thirty days. The rule already stops
after one extra refresh, because the delegation a refresh produces has to be
used to justify the refresh after it, but nothing said so.

The diamond now asks whether this delegation signed a request, and both
documents state that the question is asked of each delegation separately, which
is what bounds the chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A backgrounded tab has its timers throttled, so its delegation lapses and the
first click after the user returns waits for a mint. Becoming visible or
regaining focus is a second or two ahead of that click, which is room to hide
one. The identity still decides whether a mint is due, so a glance at a tab
holding a healthy delegation costs nothing.

AuthClient runs outside a browser too, so the identity holds no reference to a
DOM: AuthClient calls a refresh entry point on it, and the listening lives in a
separable piece constructed only where those APIs exist, as idle detection
already is. On by default, off with disableForegroundRefresh, released by
dispose(). Where there is no DOM the schedule and the request paths are the whole
mechanism and nothing is incorrect, a request after a long gap simply waits.

CookieDelegationStorage reads the same events inline and is explicitly not the
model: a cookie store is browser-only by definition and may assume what it needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check_session is II's own, for the silent re-auth path, and the overview says so.
Having the client library call it contradicted a document one PR below this one.

Nothing is lost by removing it. The library does not ask whether a session is
alive, it tries to use it, and a mint answering NoMatchingSession is the answer.
A page load already starts a mint in the background, so the mint it needs anyway
is also the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A store shaped around a bare delegation chain has nowhere to put the account key,
which is what lets a restored session answer for its principal without minting,
and its cross-subdomain hint would take a five-minute app delegation's expiry
where a sibling needs the session's.

So the client's store holds a session: the chain and the account key as one
record. The distinction is not cosmetic, since a session is what delegations are
minted from and outlives any one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec said the request carries no lifetime, which read as a property of
sessions when it was only true of the method as first built: the plumbing for a
requested maximum runs all the way to valid_for on the ceremony, and the session
handler was passing undefined where the ICRC-34 one passes what the app asked for.

maxTimeToLive keeps meaning what it meant for a delegation, the longest the thing
being granted may live, and stays a ceiling: the user's choice at consent wins
over it, an organization's cap narrows it further, and the canister clamps the
result. An access level remains the user's alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It reads the stored session and nothing else, so a page load answers without a
mint, a network call or an asynchronous store, which is what storing the account
key alongside the chain buys.

It is also optimistic about revocation, and that is a change in kind: the answer
could previously go stale only by expiry, which a client can compute, and can now
go stale because someone acted. A session revoked at the canister or from another
browser reads as authenticated until a mint is told otherwise, which is the only
thing that consults the canister.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MINT-1 said getIdentity() resolves to an identity carrying an app delegation,
while the page-load requirement says a restored session mints in the background
without getIdentity() waiting. Both cannot be true, and an implementer following
the first would block a reload on a round trip.

It now says the identity obtains a delegation when one is needed, with the two
cases named: signing in leaves it holding the one minted during the ceremony, a
restored session starts with none. Adds what the chain reports before the first
mint, since an empty chain rooted at the account key is what no authority yet
looks like and a consumer reading it deserves to know.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tabs of an origin share their storage, so they share the session, but not the
delegation minted from it: the app key lives in memory, so each tab mints for a
key of its own and five tabs cost five update calls and five stable writes every
five minutes for one person's one session.

They can share it without persisting anything. A non-extractable key survives a
structured clone, which is what lets one live in IndexedDB, and the same property
lets it cross a BroadcastChannel as a handle that signs and cannot be exported.
A tab opening asks and adopts what a running tab offers, so it mints nothing.

The design is the coordination, not the sharing, because the two things wanted
pull apart: not minting five times wants one tab responsible, not failing to mint
wants no tab load-bearing. So coordination may only suppress a mint and never be
required for one. Every tab schedules its own refresh as if alone; the channel
only offers the chance to stand down. With every message lost each tab mints,
which is the cost of no coordination rather than a failure, and no tab waits on
another when none acts.

Records what makes suppression work in practice: jittered wake-ups, because a
shared expiry otherwise wakes every tab in the same instant; re-reading before
minting; announcing a mint before making it, which narrows the double-mint window
from a mint to a message; and a timeout on a stood-down tab, whose failure mode
is a second mint rather than none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pieces are shared at different distances and the design never said so: one
session per domain, a chain and a channel per origin, a hint cookie across
siblings. A diagram now shows the nesting, and the consequence is stated plainly,
that the floor is one mint per active origin rather than one per domain, since each
origin signs with its own key and the only thing crossing between siblings is a
cookie a chain does not fit in.

That mismatch hid a defect. One session serves every sibling, so signing in on one
replaces what the other holds, and the other finds out on its next mint. Removing
the shared hint there would tell the sibling that just signed in that its session
is gone. So the acts are separated: signing out removes the local session and the
hint, finding out a chain is stale removes the local session only, which leaves the
discovering sibling in the state silent re-auth exists for. A sequence diagram
walks that recovery, and records why it works rather than leaving it looking like
luck: II keeps its record under the effective origin siblings share, so the
ceremony replaced the record the other sibling then finds.

Adds that a hint may outlive its session and so is never authority to skip asking
the user, and that two siblings asking at once is safe because asking without
rendering never creates a session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design said a chain does not fit in a cookie, which is a claim about size and
the wrong reason. A delegation is issued to a key, and each origin signs with a key
of its own, so one minted for a sibling authorises nothing here. Sharing a
delegation would mean sharing the key it was issued to, and a cookie carries bytes,
so sharing a key through one means handing out the private material to anything on
the domain that reads a cookie and to every request that carries it.

That is also what makes the difference between the two mechanisms legible: tabs of
an origin pass a key handle that signs without being exportable, and there is no
such thing to pass between origins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty seconds of jittered wake-up is ten per cent more refreshes on every active
session, which is the waste this design rejects elsewhere, and the announce and
stand-down protocol it needed was an approximation of a lock.

A named lock does the job properly. Tabs that wake together queue rather than each
starting a mint, which removes the double-mint window instead of narrowing it, so
no wake-up has to be spread out. Whoever holds it looks again before acting, since
the delegation may already have been replaced. And liveness stops resting on a
timeout: a browser releases a lock when the context holding it goes away, so a tab
closed mid-mint lets the next one proceed and nothing has to guess how long to wait
for a tab that is not coming back.

Where there is no such lock every tab mints, which is the cost of no coordination
rather than a failure, so the lock is relied on for what this costs and never for
whether it works. A request that needs a delegation now queues on it too: waiting
costs at most the mint it would have made, and often ends with another tab's
delegation and no mint at all.

Drops the wake-up jitter, the claim window and the mint result window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tab adopted a key only at first use and only if an answer arrived in time, which
fails in the most ordinary case there is: a browser restoring several tabs at once
has them all ask in the same instant with none of them able to answer yet, so each
makes its own and the origin ends up with as many keys, and as many mints, as it
has tabs. Nothing converged afterwards either.

Acquiring under the lock a mint already takes makes that start sequential: the
first tab makes a key and every tab after it finds someone able to answer. A tab's
key is settled for as long as it lives, since adopting one later would mean
throwing away a working key and the delegation issued to it, possibly with a mint
in flight, to save a call. Without a lock tabs may each keep their own and never
converge, which is the degradation already accepted and is bounded by the number
of tabs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app key was a long-lived thing an origin shared and tabs had to agree on,
which needed a lock around acquiring it, a rule that a tab's key never changes,
and an argument about why late convergence was not worth it. None of that is
needed if the key is generated by the mint that gets a delegation for it.

A key outliving its delegation carries no authority, so there was never a reason to
keep one. A mint makes a key and gets a delegation for it in one act, and the pair
expires and is replaced together, which is where rotation comes from now rather
than from a policy.

Sharing the pair is also what makes tabs converge with nothing electing a winner.
Two tabs that each ended up with a pair, because a browser restored them in the
same instant with neither able to answer, converge at the next mint: one pair is
produced, its broadcast reaches both, both adopt it. Divergence costs an extra mint
or two and lasts at most one delegation's lifetime, where sharing a key alone would
have left an origin minting once per tab for as long as those tabs lived.

Records that replacing a pair cannot disturb a request in flight, since a request
is signed and its delegation attached in the same act.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The identity is one object for the life of the session while what it signs with is
replaced every five minutes, and nothing said how both hold. It reaches the current
pair rather than holding one, which is also why an application can keep the identity
across a rotation without noticing: it never sees the app key, and the principal it
does see comes from the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client derived all three of them: the canister id from the session chain's
targets, the agent host from the origin of the authorize URL, and root-key
fetching from whether that origin was loopback. The first reads the library's own
configuration out of a credential, and the second treats a URL's origin as a
promise about which canister answers there, which holds for the deployment the
default names and for no other.

So the identity provider is two values, the authorize URL and the canister id,
each defaulting to its mainnet value, and the agent gets its own options. The
chain's targets keep their part as a check: a session chain names that canister
and nothing else, and the unrestricted case is refused hardest, because the
session key signs with that chain.

AGENT-1 through AGENT-5 replace the four that said none of this was configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design argued about three constants it never stated, so the prose reasoned in
ratios where a number would have done. It now states all five once, next to the
argument that needs them, and eight tables carry what enumerative paragraphs were
carrying: the two keys side by side, what is stored and where, the three sharing
scopes, every trigger that can start a mint, the outcomes of a failure, signing out
against finding out, and the two addresses Internet Identity is configured as.

Three claims were wrong. A tab does wait for another tab's pair, for 200
milliseconds, which the design denied while never mentioning the window at all.
The Summary described a library that mints only when a call needs one, which is not
what the Approach builds. And nothing said that upgrading signs everyone out, which
was inferable only from two out-of-scope bullets.

The scope diagram moved below the paragraphs that define its nouns, gained the
session key it had omitted, and lost an edge that pointed out of a subgraph and
meant nothing. The tab section, sixty lines carrying four arguments, is now four
subsections with a sequence for the channel, a flowchart for the lock, and its
rules as a numbered list rather than the same rule stated three times.

In the spec, AGENT-5 said an unrestricted session chain is accepted where the
implementation refuses it, which is the reversal that survived when the check was
tightened. MINT-17 cited the requirement forbidding a persisted delegation instead
of the one persisting the account key. The constants table had its columns the wrong
way round and omitted the channel name, the lock name, the mainnet defaults and the
cookie attributes, so four requirements could not be audited. A state machine, a
storage matrix, a request matrix and an options table replace prose that was
carrying the same content, and ten requirements that had imported the design's
argumentation now carry the requirement alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The account key appeared only in a diagram note, two diagram nodes and a storage
row, never in prose, and the storage row put it in localStorage next to a section
saying keys live in non-extractable IndexedDB. A reader concludes a third private
key is stored in the clear. It is a public key, it is the `user_key` the canister
returns, and the document now says so where the other two keys are introduced.

The session record was keyed three incompatible ways: four components in one
paragraph, one record per browser in a table, three components and no browser in
the recovery argument. It is keyed on the identity, the application, the account
and the browser, in all three places.

One sentence had no main clause, left by an edit that spliced a definition into
another clause. Two diagrams said the opposite of the design: the lock flowchart
asked whether a lock was available, which reads as whether it is free, so its
yes branch appeared to queue when the lock was taken; and the tab handshake drew
its branch as two consecutive notes, so read literally a tab adopted an offered
pair and then minted one anyway. The first is relabelled, the second is an
alt/else, and the hint cookie's read is an arrow from the cookie rather than to
it.

The refresh section ran sixty-three lines with no subheadings while a shorter
section had three. It has four. The count above the durations table said four
lifetimes where the table lists three and two marks.

Line wrapping was hard-wrapped in the middle third and one line per paragraph
either side, which is two formatter passes stitched together. The whole file now
matches the sibling designs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One construction was carrying most of the explanatory sentences in both files.
The colon-reveal and "rather than" pairing is down by roughly half, thirty-two
sentences whose only function was transition are gone, eighteen openings that
announced a count a table already carried are gone, and eleven paragraphs the spec
had imported from the design are back to one copy.

Four requirements said things the implementation does not do. KEY-4 forbade any
public export from returning the session key, which a pluggable store cannot
honour, so it is now about the methods of AuthClient. ERR-3 forbade a key without
its chain, which the restore path produces on purpose, because the redirect flow
writes a key on the outbound load and deleting it would destroy a sign-in in
progress. MINT-13 promised an unconditional page-load mint when that mint arrives
through the foreground trigger and inherits its conditions. API-1 called
identityProvider the only breaking change when the storage option split, keyType
went, and five exports went with them.

Two behaviours had no requirement. A delegation carrying a permissions field is
refused and the session kept, which is a sixth mint outcome the five-way diagram
did not have. And a hint whose derived expiry has already passed removes the
cookie instead of writing a stale one, so signing out is not the only path that
clears it.

API-2 said isAuthenticated reads the stored session and nothing else. With the
cookie store it also reads the hint and drops the local copy when the cookie names
another account, which is how a sign-out on a sibling reaches this origin.

The cookie's attributes are stated as the code sets them, Secure included, which
follows the scheme rather than loopback. Requirement labels are back on their own
lines after a reflow ran them into their text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An application supplies four leaves in two pairs and nothing composes
them, so a namespace is what changes slots and it changes all four.

A leaf declares whether another tab reads it, which is the one thing
only it knows; AuthClient owns the mint lock and takes it when both
halves of the app pair are shared, since sharing needs both.

A spent app delegation keeps its chain and loses its key: the chain
signs nothing without it and is the only record of the account, so the
account key stops being a stored field.

One cookie per delegation slot, the account from the app's and the
session's expiry from the session's, assembled by AuthClient.

Signing out steals the lock, so a mint in flight discards its result
instead of writing a pair back over a cleared store.

Memory leaves ship alongside the rest, with what backs them and what
the configuration gives up.
The hint is one record — the account's principal and the session's
expiry — and neither fact belongs to a credential, so it stops being
published per delegation slot and becomes a leaf supplied beside the
two pairs.

That removes what the per-slot binding had made necessary: a cookie
each, half of both unread, and an app cookie whose lifetime had to be
stretched from five minutes to the session's because its own chain
said nothing useful. One record's expiry is the session's.

A store now holds two fields and knows nothing about chains, so the
derivation lives in the library and the same two implementations serve
every configuration: a cookie to reach a sibling, localStorage to
answer this origin before a memory-backed pair has anything.
A credential is an identity and the delegation authorising it, so it is
one record under one slot. That makes the torn half unwritable rather
than something every reader has to interpret, and it retires the
requirements that existed to say what a mismatch meant.

Synchronicity moves to where the requirement actually is. Two questions
cannot wait — who is signed in, and until when — and those two facts are
the published record, so the credentials become asynchronous and the
client answers from what it read at construction. The hint becomes that
record's store and is named for what it holds.

A store declares two facts about its medium, `shared` and `durable`, and
nothing else about coordination is configurable: the lock is the
library's, the notification is the library's channel unless a store has
to move values itself, and a change is readable before it is announced.

A ceremony's key gets its own slot and is promoted on return, so a
sign-in that is abandoned or overtaken cannot disturb a live session,
and a redirect with a store that can neither keep nor share the key is
refused before it navigates.
The record of who is signed in and until when is not a summary of the
credentials; it decides what the sign-in is. Where the two disagree the
state wins and the material is discarded, and nothing runs the other
way — missing or spent material is a reason to acquire more, never a
reason to change who is signed in.

One rule then covers a sign-out in this tab, in another, on a sibling
subdomain, and a session that simply ended. isAuthenticated() and
getPrincipal() read it, so they stay synchronous without the client
caching credentials and without an answer that is only as fresh as its
last read.

The credential store loses subscribe and the client loses the channel
it would have carried: a mint changes material, and a tab that wants a
delegation takes the lock and reads rather than waiting to be told. The
state store is the only notification path left, and every medium it
uses already raises an event.

An expired app credential is removed whole. Keeping its spent chain was
only ever to remember the account, which the state now holds.
sea-snake and others added 22 commits August 31, 2026 11:02
Seven days where an application asks for nothing, so the bound applies to the
sessions it exists for rather than only the ones that opted in. Under this
library's 8-hour ceiling it is clamped away and changes nothing yet; it is what
holds once that ceiling rises, which is where the note now says so.
The idle bound, activity as its trigger and resumability were recorded as
not built. All three landed, so the "not built yet" table is gone and the
stages are grouped as the two stacks they are, with the guide and the
shared-memory store that were missing from the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
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.

2 participants