Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLIPublic
Assigneesstack72

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· Shipped 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 lastPolledAt is in the future (the clock went back), the poll is due.
  • Every poll outcome sets lastPolledAt, failed included. A server that is down then gets one try per 900 seconds, not one per command.
  • backoff sets nextPollAt = now + retryAfterSeconds, or now + 900 when no Retry-After was sent. shouldPoll is false before nextPollAt.
  • The gap is capped at 7 days: pollAfterSeconds and Retry-After are clamped to 604800 seconds, so a misbehaving server cannot silence the inbox for good. (Added during planning.)
  • unsupported, unavailable and rejected mark the key stopped, 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 > 0 and newestId is newer than announcedId, or when newestId === announcedId and now - announcedAt < 24 h. Ids are upper-case ULIDs, so "newer" is a string compare.
  • The first time a newestId is shown, set announcedId = newestId and announcedAt = 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 === 0 shows nothing, whatever the announcement says.

Summary results

  • ok replaces the cached summary and ETag.
  • not_modified keeps both.
  • failed keeps 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, stop as reason and time) and announcement (id, at). Times are epoch milliseconds. Include an empty() constructor. The same file declares the InboxCacheRepository port and the stored-value bounds.
  • inbox_policy.ts: pure functions, with now always passed in:
    • shouldPoll(state, now): boolean
    • applyPollOutcome(state, outcome, now): InboxState
    • noticeToShow(state, now): InboxNotice | null, where InboxNotice is { unread }
    • recordNoticeShown(state, now): InboxState
  • PollOutcome is the domain's own union with the same cases as InboxSummaryResult. The domain does not import src/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() from src/domain/auth/auth_credentials.ts): inbox/<fingerprint>.json under getSwampConfigDir(), 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) returns InboxState.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 schema load uses, 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 PollOutcome case
    • the 900-second floor and a larger pollAfterSeconds
    • backoff with and without retryAfterSeconds
    • 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
  • 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 newestId never shows again
    • a stopped state never polls
    • two polls are never less than 900 s apart
    • noticeToShow and recordNoticeShown never change unread
    • Log per-run coverage and mutation-check one rule, so a first green run is shown to exercise the cases.
  • poll_outcome_test.ts: every InboxSummaryResult kind maps to its PollOutcome.
  • inbox_cache_file_repository_test.ts, on a temp dir that is removed in finally (or with using):
    • 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, CI and SWAMP_NO_INBOX_NOTICE (a later PR)
  • swamp inbox and swamp inbox read
  • Skill changes
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 11 MOREREVIEW+ 7 MOREPR_LINKED+ 2 MORESESSION_SUMMARIZED

Shipped

10/27/2026, 8:50:32 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/27/2026, 7:54:24 AM

Sign in to post a ripple.