# Architecture Guide This guide documents the load-bearing architecture and invariants for `a1s`. Read it before changing model flow, runtime construction, fetch behavior, write-path handling, or stale-result guards. ## Layering The dependency direction is: ```text cmd/a1s -> internal/app/{browse, mutate, contexts, streams, keys, theme, awserror, editbuf, progress} internal/app/browse -> {awsclient, fetch, config, props, typecache, prefetchstate, app/keys, app/theme, tui/tableview, ...} internal/app/mutate -> {app/browse, app/editbuf, app/awserror, app/keys, app/theme, awsclient, fetch, config, props, ...} internal/app/contexts -> {app/mutate (ExportSlug, NewIdempotencyToken), app/{browse, keys, theme, awserror}, awsclient, fetch, config, scopecache, tui/*, ...} internal/app/streams -> {awsclient, fetch, app/{keys, theme, awserror}, props, tui/prompt} internal/app/sessions -> {awsclient, app/{keys, theme}, tui/form} runtime -> {config, fetch, prefetchstate, scopecache, typecache} config -> fetch fetch -> props awsclient -> AWS SDK + typecache props/typecache/scopecache/prefetchstate -> no project packages ``` `internal/app/*` is the importable app layer carved out of `cmd/a1s`: `keys` (the key-binding vocabulary and rebinding contract), `theme` (palette and style-set types), `awserror` (error classification), `editbuf` (the pure $EDITOR helpers), `progress` (load tracking), `browse` — the resource browsing cluster — `mutate` — the write paths: create/edit-in-$EDITOR, save/export, the delete batch, and their confirmation overlays — and `contexts` — credential/scope/SSO management: the OIDC client and token state, credential resolution, the per-scope client runtime, and the scope table with its twelve modal modes (see Model Shape). `awsSession` and the context runtime never leave `cmd/a1s`; carved packages reach AWS only through the resolver-backed facade methods below. Two edges are deliberate and easy to guess backwards: `config` imports `fetch` because persisted `FetchSettings` resolve into the consumed `fetch.Config`, which is defined next to the worker pool that reads it (so the pool needs no dependency on `config`); `awsclient` imports `typecache` only for the shared `TypeDescription` alias, the cache shape both sides exchange. `internal` packages have no Bubble Tea model dependencies. AWS SDK-backed implementations live in `internal/awsclient`; UI shaping and state stay in `cmd/a1s`. `internal/runtime` owns process-level construction: persisted stores, caches, fetch config, fetcher, fetch panic hook, and dependency slots used by tests. It does not own AWS session semantics; scope definitions, credential resolution, resource type selection, client construction, and active target transitions remain in `cmd/a1s`. `internal/tui` owns small reusable Bubble Tea components used by `cmd/a1s`: selection state, safe table replacement, filtered palettes, and named text forms. These components may wrap Charm v2 primitives, but they do not import the root `model`, start AWS work, persist config, or route screens. Screens adapt component state into app-specific commands and messages. Component-level contracts live in `docs/components/`. ## Model Shape The app uses one root `model` plus a `screen` enum. Screens are not `tea.Model`s. Each screen is a plain struct with methods shaped like: ```go update(m *model, msg tea.Msg) tea.Cmd view(m *model) string setSize(w, h int) ``` Screen methods in `cmd/a1s` mutate the root model through `*model`. Screens are still not independent Bubble Tea models — but the browse cluster (resource list, detail, filter, column picker) now lives in `internal/app/browse` behind a deliberate facade instead of `*model`: - `browse.Host` is the root model's 15-method facade (implemented in `cmd/a1s/browse_host.go`, compile-asserted). Every method is a one-line delegation to an existing model method; growth beyond that is facade leak and a signal the boundary is wrong. - `browse.Session` owns the cluster's construction-stable dependencies and is the SINGLE source for them: the shared fetcher pointer (the model owns its lifecycle), `FetchCfg`, `Prefetch`, `Keys`, `Styles`, and the session caches (`SchemaCols`, `ListCache`). The model holds no duplicate copies. The single-source rule binds the MUTABLE deps; construction values (`Keys`, `Styles`) are value-copied into every cluster Session — `Keys` stays immutable for the model's lifetime, while the `Styles` copies are rewritten exclusively through the root model's `applyStyles` funnel on live theme switches and per-scope accent changes (see Styles). - `browse.Snapshot` is the staleness seam: built on the Update goroutine by `m.browseSnapshot()` while COMMANDS are constructed, never read from a command goroutine. `Stamp.StaleFor(Snapshot)` is the single stale-guard. - Thin adapters in `cmd/a1s/browse_adapters.go` — not the carved screens — satisfy `screenController`, constructed per-lookup from the passed model. - Group fan-out bookkeeping stays app-side: `handleResourcesPage` passes a `browse.PageFlow` descriptor into `Component.ApplyPage`, so the list screen never learns about group runtimes. The mutate cluster follows the same pattern with one structural addition: `mutate.Host` EMBEDS `browse.Host` (mutate drives the browse component, whose methods take a `browse.Host`) plus 13 write-path methods — the per-scope resolvers (`Mutator`/`MutationWaiter`/`SchemaDescriberFor`/ `PriorityDetailGetter`), the overlay/surface seam (`OpenOverlay`/ `CloseOverlay`/`BodySurface`/`ActiveRef`), group labels, and the `ShowError` funnel. `mutate.Session` is the single source for the type-description cache and the resolved `Delete`/`BulkWarnings` config snapshots. The package owns both ends of the `tea.ExecProcess` editor round-trip (`EditorCmd` → `EditorClosedMsg`; staleness matches by session temp-file path — the documented exception to stamp-based guards) and the export job's OS resources. Cross-cluster seams stay app-side and explicit: the app's detail-ready handler feeds `HandleExportDetailReady` and then recomputes the spinner (`ExportJobActive() || !fetch.Idle()`) — the export job never owns the global spinner. The contexts cluster (`internal/app/contexts`) completes the carve. Its `contexts.Host` does NOT embed `browse.Host` — contexts precedes browsing and drives no browse components — and is seventeen methods: live config access (`Config`/`ApplyConfigMutation`/`PersistConfig`/`SaveCreds` — this cluster WRITES config, so snapshots would go stale), the encryption gate, status and spinner (including a `Status()` READ, the 17th method, accepted so flows can avoid clobbering warnings deeper activation paths just set), the surface seam (`OpenSurface` returns an opaque `SurfaceToken` that `RestoreSurface` interprets — how the credential challenge and encryption gate restore the screen they interposed over), and the runtime wiring (`ActivateTarget`/`ActiveTypeName`/`RegionRuntime`/`FinishScopeDelete` — `awsSession` and the activation ladder stay in `cmd/a1s`). `contexts.Component` is the single source for the scope index, config issues, the credential runtime, and credentials; the table's twelve modal modes register in the package's own mode registry (`ModeEntry` over `(*Component, Host)`), so mode transitions never touch app enums. SSO/token material is sealed unexported inside the package: nothing exported returns raw registration or token fields beyond the `config.SSOCreds` snapshot the persistence path requires. Cross-cluster status reads (activity text, text-entry focus, mode checks) are exported Component methods read directly by the shell — `cmd/a1s` → `contexts` is the free direction, so the Host never grows for status taps. The streams cluster (`internal/app/streams`) is NEW functionality built to the carve conventions rather than carved out of `cmd/a1s`: the event-stream pane over CloudWatch Logs and CloudTrail event history, plus the scope change watch. `streams.Host` is three methods (`SetStatus`/`SetBusy`/ `CloseStreamOverlay`, compile-asserted) — sources resolve app-side at open time through the row's origin scope, and async results stale-guard inside the package on the pane's own generation. `streams.Session` is the single source for the shared CloudTrail 2 TPS `fetch.KeyedLimiter`; every `LookupEvents` caller (pane source, watch) paces through it per credential+region bucket. The sessions cluster (`internal/app/sessions`) follows the same NEW-feature shape: the SSM instance-session modal behind `!` on `AWS::EC2::Instance` rows (kind picker, port form, confirmation, managed-instance verification) and both ends of the `session-manager-plugin` `tea.ExecProcess` handoff with AWS-CLI-shaped argv. `sessions.Host` is the same three methods as streams (`SetStatus`/`SetBusy`/`CloseSessionOverlay`, compile-asserted); the SSM client resolves app-side at open time through the row's origin scope, and verify/start results stale-guard on the modal's own generation. The plugin exit matches the live handoff's session id instead — the modal closes before the terminal handoff, so its generation has already moved (see Stale-Result Guards). See `docs/screens/session.md`. Every cluster boundary carries TWO channels. Host interfaces carry app-effect WRITES: capped, compile-asserted facades whose growth is the leak alarm. Exported Component methods carry shell READS — `cmd/a1s` → cluster is the free direction, so the Host never grows for a status tap. The read channel has no leak alarm: nothing fails the build when a Component sprouts another exported read, so boundary reviews must watch BOTH channels, not just the facade method count. Do not split the remaining `cmd/a1s` screens into independent Bubble Tea models. There is no remaining cluster to carve: `cmd/a1s` is the composition root — the root model and dispatch, the screen/overlay registries and thin adapters, the shell frame, the command palette and resource-type picker, startup/resume routing, and `awsSession` with the client-resolution ladders. Reusable `internal/tui` components are embedded inside these screen structs. They own local interaction state only; screen methods remain responsible for root-model mutation, app effects, and command creation. Screen and overlay ownership lives in `docs/screens/`. Not every extraction belongs in `internal/tui`. App-specific pure helpers such as command/resource matching, resource-table column projection, SSO registration workflow handling, and SSO role row merging stay in `cmd/a1s` when they depend on app concepts like credentials, scopes, resource aliases, or AWS discovery state. ## Update Dispatch `model_update.go` handles global and top-level messages before routing to the active overlay or screen. The important order is: 1. Window sizing and global keys. 2. Spinner ticks. 3. Top-level errors and async results. 4. List, detail, schema, editor, mutation, and resume messages. 5. Active overlay if one is open. 6. Active screen otherwise. This order is load-bearing. Keep stale guards and top-level error/mutation routing in the same relative position unless a focused test proves the new order. Mutation, page-error, and screen-local error messages must not be hidden by an open overlay. Screen/overlay transitions are deliberately NOT funneled through a single helper: each `m.overlay`/`m.state` write site carries flow-specific status, spinner, and size-propagation ordering, and a parameterized transition helper was evaluated (2026-06) and rejected as hiding those semantics. The shared rituals that do exist are `goBack()`, `closeOverlay()`, the registry back handlers, `contextTableReturn`, and the `setScreen`/`setOverlay` micro-helpers (exactly assignment + `propagateSize()`, nothing else) — use those where they fit; otherwise write the transition inline next to its flow. ## Overlays The command/resource-type prompt can render as the full-screen resource-type picker after scope selection and as the `:` overlay over the browser and top-level tables when no modal is active. `overlayKind` selects modal browser surfaces. The authoritative overlay list is `overlayRegistry` in `cmd/a1s/registry.go`; `docs/screens/README.md` mirrors it with one ownership doc per surface. Two overlays carry load-bearing semantics worth calling out here: - `overlayConfirm`: shared yes/no confirmation. - `overlayCredentialEncryption`: first-use modal passphrase prompt before sensitive SSO credentials can be written. Top-level non-browser surfaces: - `screenContextTable`: credentials, scopes, and read-only resource-type aliases. There is no action menu. Explicit row actions are dispatched directly from the browse key handler: `Y` (copy-identifier, OSC 52 clipboard) is the first example of this pattern. `goBack()` closes an overlay first. Without an overlay, list/detail screens step back to the command/resource-type prompt or the list. See `docs/screens/` for per-surface ownership and maintenance notes. ## Service Actions Type-scoped row actions — open the CloudWatch logs of a log group, start an SSM session on an EC2 instance, invoke a Lambda — plug in through the service-action registry (`cmd/a1s/service_actions.go`), the row-action sibling of `screenRegistry`/`overlayRegistry`. Adding a service hotkey is appending one `ServiceAction{Key/KeyFor, Applies, Factory, Section, ReadOnly}` entry; the browser key dispatch and the fullscreen help's per-service sections are both sourced from that one table and are closed for modification. - `Applies(m, typeName)` is the type-level gate (log-capable type, SSM-managed type, catalogued metric type). It drives both header help and which keys dispatch considers. - `Factory(m)` is the only place the surface opens and the only place an AWS-mutating action's `readOnlyGate` fires (after the selection gate, before opening), so a gated-but-unavailable key still falls through silently. A `handled == false` lets the key reach the screens (e.g. `l` opens logs on a log group but pans columns elsewhere). Each service feature (S3, DynamoDB, Kinesis, SQS, Step Functions, SNS, Secrets Manager, ECS) lives in its own `internal/app/svc/` cluster behind a small compile-asserted Host, with its AWS client in `internal/awsclient/.go`, a lazy origin-scope accessor in `cmd/a1s/aws_.go`, and a Smithy-gated mock in `internal/testworld/mock_.go` — the append-only contract in `docs/recommendations/service-client-convention.md` keeps parallel service work from colliding. ## View And Layout `shell_view.go` owns `View()`, active body dispatch, prompt/body routing, and modal compositing before wrapping the rendered frame with `tea.NewView`. `internal/tui/shell` owns the effect-free frame layout primitive. Call `propagateSize()` after any layout-affecting state change. Hotkey help is context-sensitive and comes from the active controller. Controllers can implement `helpLayoutController` to split help into header actions and footer navigation. Table surfaces keep movement, selection/back, command-palette, mark/range, and global help in the footer; contextual verbs such as create, edit, delete, refresh, save/export, YAML, SSO actions, and rename belong in the header. The header action band shows at most 4 rows x 2 pairs (8 slots) in helpLayout order; lower-priority actions overflow into the fullscreen `?` help surface (`overlayHelp`, `docs/screens/help.md`), which closes back to the exact prior surface. `ctrl+c` remains a hard quit path but is not listed as footer help; the user-facing quit command is `q!` from the command prompt. `shell_status.go` owns breadcrumbs, right-aligned activity text, toast/footer help, and text-fitting helpers. `shellFooter`, `helpBindings`, and `helpLayout` must be side-effect-free reads of `model`: they run inside every rendered frame. Breadcrumb ordering invariant (`shellBreadcrumbs`): the active resource type is the base of the stack, and an operation only ever opens a new crumb to the RIGHT of the stack, replaces the rightmost crumb, or replaces the whole stack — NEVER to the left. So the shape is always ` ` (or ` `) for a type-scoped operation, and a lone `` for a global one (credential, encryption, scope switcher, help — see `overlayReplacesStack`). The transient input prompts (command palette, resource filter) carry no crumb. Frame heights are fixed contracts shared with `internal/tui/shell`: `bodyHeight()` subtracts `tuishell.HeaderHeight`, `tuishell.FooterHeight`, the body border, and the command prompt's lines when it is visible. Heights are not measured per frame; if chrome gains or loses a line, change the shell constants and `bodyHeight()` together. Widths that must fit content are still measured with `lipgloss.Width`. ## Styles Styles are a value object with a single source and no package globals. The palette, `Styles`, and `Skin` types (plus the embedded built-in skins) live in `internal/app/theme`; `cmd/a1s/styles.go` derives the model's base palette from the config (`paletteForConfig`, pure given the filesystem: built-in defaults ← the `skin:` file or built-in ← the inline `theme:` block, with invalid colors ignored field-wise — see `docs/CONFIGURATION.md`). `newModel` wires the derived `Styles` value into `m.styles` and value-copies it into the cluster Sessions — theme application rides the config into construction, so there is no startup ordering to get wrong. Render paths read `m.styles` (or their Session's copy); helpers that need styles take the model or a `Styles` value. Styles are NOT immutable after construction: the `:theme` command and per-scope accent overrides (config `accent:` on credentials/scopes, applied on target activation and cleared on teardown) rebuild them live. Every change funnels through the `refreshStyles`/`applyStyles` pair in `cmd/a1s/styles.go`: `refreshStyles` recomputes from `m.basePalette` plus `m.scopeAccent`, and `applyStyles` writes the ONE resulting value into `m.styles`, the help model, and each cluster Session's copy (mutate reads the browse Session through its embedded Host). Do not write a Session's `Styles` copy directly — a copy updated outside `applyStyles` is exactly the stale-copy bug the funnel exists to prevent. `switchSkin` additionally persists the `skin:` choice and calls `propagateSize()`. The shell receives header label/value/logo styles plus body and prompt border colors from this value object. Table and modal body borders use the body border color; the command prompt uses the prompt border color. Table constructors use `appTableStyles()` so selected rows render consistently with Lip Gloss reverse video. Shell body titles render the table name, optional scope in parentheses, and item count in brackets; punctuation, scope, and count styles are split so the ANSI16 palette can color them independently. ## Scope And Resume Browsing scope is built from v3 scope definitions: - `Credential`: SDK profile, assume-role, SSO registration, or SSO role. - `RegionScope`: one credential plus one AWS region. - `GroupScope`: a named collection of region scopes browsed together. - `ActiveScope`: persisted scope kind/id plus resource type. The `ScopeIndex` in `internal/config` validates scope references, resolves aliases, and preserves the shared first-come-first-served alias namespace for region and group scopes. Resource type aliases live in the command-prompt first-token namespace and must not shadow built-in command aliases. `newModelWithRuntime` runs the startup router on every launch. A complete `activeScope` resumes into the resource list. When there are no credentials and no scopes, startup opens the credentials table so the first useful action is credential creation. Other incomplete startup states open the scope table. If a selected scope has a remembered `activeScope.resourceType`, scope activation loads that resource table directly; otherwise it opens the resource-type picker. The header derives from the active region scope or group runtime plus the active resource type. Display aliases are read from the refreshed scope index so scope renames update the context header and resource table title without rebuilding the AWS clients. Do not add a third stored breadcrumb. Credential identity is resolved before a `RegionScope` becomes active. SSO role credentials carry account and role metadata from config. Assume-role credentials derive the account from the role ARN. SDK profile credentials first use `aws.Credentials.AccountID` when the provider supplies it; otherwise `contextCredentialResolver` calls `sts:GetCallerIdentity` once per resolved credential and caches the resulting account ID with the runtime identity. Header account counts must read that runtime identity rather than guessing from table state. ## Sensitive Credentials `internal/config.CredStore` owns sensitive credential persistence. Non-sensitive credential definitions live in `config.yaml`; SSO client registration secrets and SSO access/refresh tokens live in `credentials.yaml`, keyed by `ssoRegistration` credential ID. The file-backed store writes non-empty `Creds` as an age passphrase-encrypted YAML envelope with mode `0600`. Missing, empty, or zero-value credentials files load without a passphrase so fresh startup and `--reset-config` remain simple. Non-empty plaintext credentials are intentionally rejected instead of migrated. Startup checks whether `credentials.yaml` is encrypted and prompts for the passphrase before model construction. During TUI use, the first SSO registration or authorization action that would persist sensitive material opens `overlayCredentialEncryption` if the store has not been unlocked yet. The modal explains that the passphrase protects sensitive material such as SSO client secrets and refresh tokens. The passphrase stays in process memory and is only visible through the `CredStore` interface. ## Session Caches Within-session maps are keyed from the active `browseScopeKey()` or from `(scope, type)`: - `typeCache`: resource type lists backed by the global type-name entry in `scopecache`. - `schemaCache`: column names. - `listCache`: raw `ListResources` page data only, not rendered rows. - `createSchemaCache`: parsed schema and `ProvisioningType`, backed by global `typecache`. Rows are re-derived on restore so refreshed detail can be reflected. List refresh deletes the `listCache` entry before paging so it genuinely reloads. `internal/scopecache` also stores non-sensitive discovery rows that are slow to re-list during scope setup: - SSO account/role rows keyed by SSO registration credential ID plus an SSO registration fingerprint. - Enabled region rows keyed by credential ID plus a credential fingerprint. - Global public CloudFormation resource type names, shared across scopes. ## Detail Fetch And Prefetch `internal/fetch` provides two lanes: - Background detail prefetch through `EnqueueDetail` or scoped `EnqueueDetailRequest`. - Priority detail fetch through `EnqueueDetailPriority` for the resource the user just opened. The priority lane uses a dedicated CloudControl client. `Reset(gen, ...)` cancels the prior generation's context. Background `GetResource` prefetch is gated by `shouldPrefetchDetails(typeName)`, backed by `internal/prefetchstate`. It runs only for types with sparse rows or columns omitted by `ListResources`. All detail reads use the same fetcher admission path and carry a rate bucket key. In production that key is the resolved credential plus AWS region, so `fetch.ratePerSecond`, `fetch.burst`, and `--rate` apply per credential/region bucket while `detailFetchers` remains the global concurrency cap. In group browsing, detail requests carry the row's origin scope, type, identifier, getter, and rate bucket so a row from one region is never fetched through or throttled with another region's client. On-demand detail fetch is never gated. List page fill is not gated and continues while priority detail fetches are in flight. With no prefetch state store wired, tests default to prefetching everything. ## Write Operations Create, delete, and update confirm through the shared confirm screen, then monitor completion asynchronously and refresh. The initial mutation command returns `mutationStartedMsg`. Its handler starts `awaitMutationCmd`, which waits on the CloudControl resource request waiter and returns `mutationDoneMsg`. The waiter timeout is separate from the shorter initial AWS call timeout. Create/edit use the user's `$EDITOR`; there is no in-TUI editor. Shared editor mechanics live in `model_editor.go`: - Temp file creation. - Editor launch and close handling. - Path ownership checks. - File read. - Error annotation. - Unchanged-body cancellation. - Reopen behavior. Operation policy remains separate: - Create builds a skeleton and validates desired state. - Edit loads fresh current state, validates a diff, computes a JSON patch, and renders a unified diff. - `NON_PROVISIONABLE` types block create. - `IMMUTABLE` update confirmation includes a warning because the operation deletes and recreates. `createSess` and `editSess` stay alive through the waiter because async failures can reopen the editor with the error annotated. `sourcePath` rides mutation messages to match the result to the right session. Terminal paths clear the session and remove the temp file. Delete and edit work from list and detail. Create, columns, save/export, prefetch-all, and grouped errors are list-only. Edit and delete are row-scoped. In a group view, they use the selected row's origin `RegionScope`, not the active group as a whole. Resource-list origin metadata is displayed through normal columns. Group views include `AccountID` and `Region` by default until a per-type view is saved; the saved column order is exact, so group and single `RegionScope` views can hide or reorder those columns from the column picker. Marked delete uses the same batch machinery as single-row delete. A confirmed batch starts delete requests in parallel chunks. On the first failure, the delete-error prompt asks the operator to abort, retry, or continue, with a batch-scoped ignore-further-errors checkbox seeded from `delete.ignoreFurtherErrorsDefault`. Create in a single region scope uses that scope. Create in a group opens a flattened region-scope selection overlay first, then submits only to the chosen scope. ## Stale-Result Guards Commands run concurrently, so results can land after navigation. Unguarded handlers paint stale data. When adding a loader: 1. Decide what the payload is a function of: generation, `(scope, type)`, or both. 2. Put that discriminator on the message struct. 3. Check it in the handler. 4. Drop the result on mismatch. Browse-context loaders (list pages, streamed details) embed `browseStamp` (`browse_scope.go`) and guard with `stamp.staleFor(m)` — the recipe above as one piece of code. Empty stamp fields skip their check: details carry no target scope because they patch rows by `resourceRef`, not by browse target. Use `gen` for per-browse content: detail, list pages, and mutations. Generation bumps on every scope/type/refresh re-entry to the list. List refresh keeps the same scope/type but bumps `gen`, so scope/type-only guards are insufficient. Use `(scope, type)` for pure functions of scope/type: type lists, schemas, type descriptions, and edit preparation. Known exceptions: - SSO account/role load messages are keyed by `CredentialID` plus an SSO registration fingerprint because they derive from the selected SSO registration credential. - Credential region load messages are keyed by `CredentialID` plus a credential fingerprint because they derive from the selected credential provider payload. - Editor close messages match the current create/edit session path. - The SSM session plugin's exit message matches the live handoff's session id (`sessions.PluginExitedMsg`): the modal closes before the terminal is handed to `session-manager-plugin`, so the modal generation has already moved, and `tea.ExecProcess` is exclusive — nothing can supersede the handoff while it runs. - Related-resource peek results are keyed by peek token (`peekDetailReadyMsg` in `cmd/a1s/screen_peek.go`). A peek is deliberately outside the browse target — its result stays valid across list refreshes, and a popped peek must drop its late result even though the browse generation never moved — so the handler matches the token against the live peek stack and drops misses. - Event-stream pane results carry the PANE generation (`streams.Pane.Gen`, bumped on open/refresh/range/filter/close): the pane is a function of its own life, not the browse target — the browse target cannot change beneath the open overlay. Live tail sessions arriving under a stale generation are closed on arrival so the stream never leaks. - Device-auth polling is deliberately unguarded. Only the SSO registration modal rearms polling, so navigation away strands the chain harmlessly. Screen-local load failures should use a discriminated message type, per-screen `err`, top-level routing, and clear-on-load behavior. `m.status` is for global or transient notices. ## Invariants And Gotchas - Charm v2 is mandatory. Root `View()` returns `tea.View`; keyboard events are `tea.KeyPressMsg`; bubbles components render `string` that the root wraps in `tea.NewView`. - Do not use test helpers that target Bubble Tea v1 model interfaces. - `ssoClient` token-refresh methods must use pointer receivers so refreshed tokens persist on the shared instance. - Bubble Tea v2 recovers `tea.Cmd` goroutine panics. Do not wrap ordinary command loaders in `recover()`. - Raw goroutines started outside Bubble Tea need their own panic recovery. The fetch pool does this because its workers are raw goroutines. - CloudControl `ListResources` rows can be sparse; columns such as ARN may be blank until `GetResource` fills detail. - Surface AWS errors through `formatAWSError`, classified by smithy error code. Do not classify by message-string matching or token-bearing fields. - Scope runtime construction must resolve credentials before building AWS clients. Surface credential challenges through the prompt broker; do not read MFA or refresh input from stdin. - `RegionScope` is always single-region. Multi-region browsing is a `GroupScope`. - `browseScopeKey()` must distinguish a region scope from a group scope so duplicate resource identifiers across scopes cannot collide.