Relationships
#3997 Inbox notice policy and local inbox cache: when to poll, when the post-command notice shows and retires, and a cache file per API-key fingerprint, with no wiring yet
Opened by stack72 · 10/27/2026
Summary
Add the inbox notice policy and the local inbox cache: the domain rules for when to poll and when to show the post-command notice, and the file that remembers the last summary. This is the second PR of the inbox slice. It builds on swamp-club#3990 (merged in #3398), which pinned the contract in design/surfaces/inbox.md and added SwampClubClient.inboxSummary.
Nothing user-facing changes. No command calls this code, nothing polls and nothing prints. A later PR wires it into runInvocation.
Rules (from design/surfaces/inbox.md, "How the CLI uses it")
Polling
- Poll only when no poll has happened yet, or when
now - lastPolledAt >= max(pollAfterSeconds, 900)seconds. The server can lengthen the gap but never shorten it below 900. - If
lastPolledAtis in the future (the clock went back), the poll is due. - Every poll outcome sets
lastPolledAt,failedincluded. A server that is down then gets one try per 900 seconds, not one per command. backoffsetsnextPollAt = now + retryAfterSeconds, ornow + 900when noRetry-Afterwas sent.shouldPollis false beforenextPollAt.- The gap is capped at 7 days:
pollAfterSecondsandRetry-Afterare clamped to 604800 seconds, so a misbehaving server cannot silence the inbox for good. (Added during planning.) unsupported,unavailableandrejectedmark the keystopped, recording the reason and time so a later retry rule needs no migration. A stopped key never polls again. A new key (a new fingerprint) starts fresh, so signing in again, or a server that later adds the inbox, recovers.
Notice
- Show the notice when
unread > 0andnewestIdis newer thanannouncedId, or whennewestId === announcedIdandnow - announcedAt < 24 h. Ids are upper-case ULIDs, so "newer" is a string compare. - The first time a
newestIdis shown, setannouncedId = newestIdandannouncedAt = now. - A newer message replaces the announcement and starts a fresh 24 hours. The notice retires 24 hours after it first showed, whether or not the message was read. The notice never changes read state.
unread === 0shows nothing, whatever the announcement says.
Summary results
okreplaces the cached summary andETag.not_modifiedkeeps both.failedkeeps both, so the notice still works from the last good summary.
Design
Domain: src/domain/inbox/
inbox_state.ts:InboxState, an immutable value object made of three parts that change for different reasons (changed during planning from a flat set of fields):snapshot(the last good summary:unread,newestId,newestAt,pollAfterSeconds,etag),schedule(lastPolledAt,nextPollAt,stopas reason and time) andannouncement(id,at). Times are epoch milliseconds. Include anempty()constructor. The same file declares theInboxCacheRepositoryport and the stored-value bounds.inbox_policy.ts: pure functions, withnowalways passed in:shouldPoll(state, now): booleanapplyPollOutcome(state, outcome, now): InboxStatenoticeToShow(state, now): InboxNotice | null, whereInboxNoticeis{ unread }recordNoticeShown(state, now): InboxState
PollOutcomeis the domain's own union with the same cases asInboxSummaryResult. The domain does not importsrc/infrastructure/http/inbox_schemas.ts.
Mapper
src/libswamp/inbox/poll_outcome.ts holds toPollOutcome(result: InboxSummaryResult): PollOutcome. It is internal to libswamp and not exported from mod.ts yet. It lives here so the wiring PR is wiring only.
Cache: src/infrastructure/inbox/inbox_cache_file_repository.ts
Changed during planning from one shared inbox.json to one file per key, so saving one key can never overwrite another.
- One file per API-key fingerprint (
keyFingerprint()fromsrc/domain/auth/auth_credentials.ts):inbox/<fingerprint>.jsonundergetSwampConfigDir(), the same directory as the update preferences. Inject the directory in tests. Switching between a personal key and a collective key then never mixes up or resets either one's state. Keep at most 8 key files and drop the least recently saved, never the one just written. - The fingerprint must be 16 lower-case hex characters before it becomes a file name, so no input can name a path outside the directory.
load(fingerprint)returnsInboxState.empty()when the file is missing, unreadable, over 4 KiB, corrupt, or outside the stored-value bounds. Validate with zod; invalid data reads as empty and is never thrown.save(fingerprint, state)validates with the same schemaloaduses, then writes atomically (temp file and rename), so a crash never leaves a half-written file and whatever is saved always loads. Two processes saving the same key at once means last writer wins. The worst case is one repeated notice or one extra poll, which is acceptable. The caller saves only when the process owns the config dir (processOwnsConfigDir(), as the update preferences do). The repository does not decide that.- Format changes are additive only, and each added field must be safe to lose. A breaking change moves to a new directory, so two installed swamp versions never share a file.
- The file never stores the API key, only its fingerprint.
Tests
inbox_policy_test.ts, unit tests:- each
PollOutcomecase - the 900-second floor and a larger
pollAfterSeconds backoffwith and withoutretryAfterSeconds- the clock going back
- the notice showing, repeating within 24 h, retiring at 24 h, restarting for a newer id, and staying silent for
unread === 0
- each
inbox_policy_property_test.ts, fast-check over random outcome and time sequences:- no notice is shown 24 h or more after its
announcedAt - a retired
newestIdnever shows again - a stopped state never polls
- two polls are never less than 900 s apart
noticeToShowandrecordNoticeShownnever changeunread- Log per-run coverage and mutation-check one rule, so a first green run is shown to exercise the cases.
- no notice is shown 24 h or more after its
poll_outcome_test.ts: everyInboxSummaryResultkind maps to itsPollOutcome.inbox_cache_file_repository_test.ts, on a temp dir that is removed infinally(or withusing):- round trip
- a missing, corrupt or invalid entry reads as empty
- per-fingerprint isolation
- the 8-key cap
- an atomic write leaves no temp file behind
Run all of integration/ before verifying. New domain files can trip architecture_boundary_test.ts, which is not a *_rules_test.
Out of scope
- Wiring into
runInvocation: starting the poll, the 250 ms teardown cap, printing the notice, the skipped commands,CIandSWAMP_NO_INBOX_NOTICE(a later PR) swamp inboxandswamp inbox read- Skill changes
In Progress
Click a lifecycle step above to view its details.
Sign in to post a ripple.