Skip to content

feat(apps): restore where a builtin app page left off - #8407

Merged
chenmingwei23 merged 1 commit into
feat/builtin-app-identity-seamfrom
feat/app-view-state-store
Sep 4, 2026
Merged

feat(apps): restore where a builtin app page left off#8407
chenmingwei23 merged 1 commit into
feat/builtin-app-identity-seamfrom
feat/app-view-state-store

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Part of the app view-state and cache-retention work. Three PRs, one prerequisite:

#8407 and #8404 are siblings, not a chain: either may merge first once #8403 is in.
Related: #8412 (stale bundle ceiling on main, merged), #8394 (external app cache
isolation, separate track), #8401 (convert the remaining AWS Control key sites,
after #8407).

This PR is the view-state store section of that design.

1. What is the problem?

A builtin app page is unmounted on navigation. BuiltinAppRoute renders one lazy component under a catch-all route, React Router tears it down when you leave, and component-local state returns to its defaults on the next visit.

AWS Control's drive is the reported case. The current folder lives in useState('') at DrivePage.tsx, and nothing writes it anywhere durable, so descending into a folder, leaving, and coming back puts you at the bucket root again.

The platform had nowhere to put that state. There was no shared store, no namespace an app could be given, and nothing that decided what happens when a saved record can no longer be read.

2. Why this issue matters to the user

An app that forgets where you were on every visit cannot be a place you come back to. It pushes you toward one long-lived session and penalises navigating at all, which is the opposite of what a dashboard made of many small apps is for.

The cost is not only attention. The drive listing is refetched from the root on every return, so the user waits behind a skeleton for data they were already looking at a moment ago.

Worth being honest about the size of the delta here: for this app it is ONE field. The active pane is already in the URL via usePaneFromPath, the grid/list choice already persists through useViewMode, and the selected account already persists through usePersistedString. The state the design's ownership table says to exclude largely does not exist in this app at all -- there is no scroll-position state, no sort order, no expanded-tree model. What was missing is the folder, plus the scope that makes a folder meaningful.

Screenshot evidence

Captured from an isolated pod on port 7944 (zero AWS accounts, zero prior sessions) running the served bundle whose AwsControlPage chunk contains kc:app: x1, :view: x1, and the old per-app key shape x0.

Dashboard with AWS Control enabled in the sidebar -- the app is reachable and renders the Accounts & credentials page (no AWS accounts connected on the pod, so the Drive pane is gated behind drive?.exists and cannot show the folder restore).

AWS Control enabled, showing Accounts & credentials page with the app visible in sidebar

Discover page listing AWS Control with the Enable button -- the app card on the app store, before enabling.

Discover page showing AWS Control among the built-in apps

Clean dashboard on a fresh pod -- no prior sessions, no PII.

Dashboard welcome screen on a fresh pod with no sessions

The folder restore behaviour itself (descend into a subfolder, navigate away, return, land in the subfolder) cannot be demonstrated on this pod because DrivePaneGate requires a connected AWS account with an existing S3 bucket. The behaviour is covered by the 265 passing tests including the mutation matrix in section 4; a live demo requires a pod with AWS credentials.

(If a reviewer's own raw-URL viewer does not render the images, open the Files changed tab -- the PNGs are in temp-screenshots/app-view-state-store/.)

3. How our fix solves it

A host-owned view-state store: website/src/app-sdk/viewState.ts. An app declares the few coordinates worth restoring; the host owns the key and the failure policy.

Chaining from the symptom to the root cause:

  • The symptom is "the drive is back at the root". Directly, that is because path was component-local and the component is unmounted on navigation.
  • So path now comes from the store instead of local state. The five existing setPath(...) call sites are unchanged, and there is one source of truth for the folder rather than two.
  • But a saved folder is worthless if it is read too late, because the drive's infinite query is keyed on path. So the record is read in a useState initializer: the restored value exists on the consumer's FIRST render, and the first driveList request is for the restored folder. No wasted listing of the root, no skeleton on the way to somewhere the user was not going. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and would be a render too late on the repeat visit this feature exists to serve.
  • And a saved folder is worse than worthless if restored in the wrong place, because a prefix means nothing outside the bucket it was taken in. So the scope is part of the KEY: kc:app:<appId>:view:<name>:<scope>. A record cannot be read under the wrong scope, which makes the isolation structural rather than a comparison someone can forget -- and each account keeps its own position, so a user working across two accounts finds both folders where they left them.
  • The deeper root cause is that an app had no namespace to be given. The key is kc:app:<appId>:view:<surface>[:<scope>], with appId read from identity context through useTrustedAppId() -- a namespace is granted only for a builtin origin, and refused for a host page or an external app. An app cannot name its own namespace, because the id is not a parameter it passes. The identity that hook reads is what this PR is stacked on.

Two properties are mechanisms here rather than rules someone is trusted to follow:

pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Handing the store a whole component state persists only the coordinates. This is what makes "do not persist the drive's contents" an enforced property instead of a documented intention, and a test passes contents in and asserts they are dropped.

useTrustedAppId() is the single builtin gate, read through that hook rather than by testing origin at each consumer. Its null covers both a host page and an external app.

Grounding for the scope choice

account is always a resolved, non-empty id by the time DriveSectionView first renders: AwsControlPage returns the accounts pane while !selected, and DrivePaneGate yields children only once drive?.exists. This is load-bearing rather than incidental -- if account were briefly the empty string, the scope would never match and the restore would silently never happen. Stating it because a reviewer cannot otherwise tell that it was checked.

One record per SURFACE, not per app

Raised by Design Review, and it was right. The key first read kc:app:<appId>:view -- one record per app -- while the hook is consumed per component, each with its own declaration. So a second consumer in the same app (the library or backup pane) would write only ITS declared fields and erase the drive's folder.

The erasure is not the bad part. Reproduced against the real functions before fixing: the drive reads back that record, finds path merely ABSENT, falls back to the default, and reports outcome restored. A silent loss dressed as a successful restore, invisible to either consumer's own test suite.

So ViewStateDecl now carries a required name and the key is kc:app:<appId>:view:<name>. Fixed in this PR rather than when a second consumer appears, because an in-blob revision cannot express a change to the KEY's shape -- by then the old key holds real user records that nothing can migrate. Right now the format has zero installed records, so it costs nothing.

A key segment rather than several sections inside one record, deliberately. A shared record has to be read-modify-written by every consumer, which is the shape that lets one writer clobber a field it holds a stale copy of. Separate keys give each surface an independent lifetime, so a corrupt record on one pane cannot take another pane's position with it.

name is validated as a key segment on the same charset as appId, and throws rather than sanitizing, following AppScopedApiProvider's precedent in this layer: it is authored in code as part of a module-level declaration and is never data, so a bad one is a developer error that should fail identically everywhere.

Why the existing awsControl.* keys were not moved

The selected account (awsControl.selectedAccount) and the view modes (awsControl.drive.viewMode.*) stay exactly where they are.

Relocating working keys costs either migration-read code or silently dropping every current user's stored account, for no user-visible gain. The account is already durable, and its consumer already resolves an id that no longer exists by falling back to the first resolved account -- that is account business logic, not view position. What this PR adds instead is the guarantee neither store had: the account travels in the record as its SCOPE, so a folder can never be restored into a bucket it was not taken in.

View mode stays out for a different reason: it is a display preference with a deliberately different lifetime. Folding it in would make it account-scoped, so switching accounts would flip the user's grid/list choice, and a revision bump would silently reset it.

Scope lives in the key, and that DELETED machinery

Design Review's second round accepted the shape but priced its cost: a single record tagged with one scope meant a real position under account A overwriting account B's, so a user switching accounts lost their folder every time -- while the trivial per-account-key alternative retains both for free. That was a fair charge, and the fix turned out to remove code rather than add it.

Moving the scope into the key deleted: the scope field in the record, the scope comparison in parseViewState, the scope-mismatch outcome and its whole reporting tier, the "is this record even mine" branch in resolveViewStateWrite along with the stored-record read and parse it needed to answer that, and the second half of the snapshot comparison in the hook.

What is left is smaller and says more. resolveViewStateWrite is now two cases -- defaults remove, anything else writes -- and takes no stored record at all, because a record under this key can only ever be this scope's. The hook compares ONE thing, the key, and that single comparison now covers a namespace granted late, an appId change, AND a scope change, because all three are the same fact: the state in hand belongs to a different address than the one being asked for.

scope is DATA, unlike name, so it is percent-encoded rather than validated and refused. Encoding escapes :, which is what stops one scope value from forging a segment boundary into another scope's key -- there is a test for a:b and for owner/repo.

The cost is one small record per scope the user actually visits, which is the contract the key shape implies and the spec now states: scope names a subject the user chooses among, such as an account, not a per-item id.

The exported surface is only what has a consumer

First Principles found that seven exports had zero non-test consumers, and it was right on the facts -- I checked, and readPersistedString, the precedent I expected to lean on, turns out to have a real consumer in RemoteCrewPanel.tsx, so it does not support test-only exports at all.

pickDeclared, isDefaultState, serializeViewRecord, parseViewState, resolveViewStateWrite and viewStateKey are now module-internal, along with the three types that only described their signatures. What remains exported is useAppViewState, isViewString and the two declaration types -- four names, all with real consumers.

Unexporting the key derivation made the tests stronger rather than weaker. They now assert the key as a LITERAL string (kc:app:aws-control:view:drive:<account-id>), which states the on-disk contract; recomputing it with the function under test would have let a dropped segment agree with itself.

The record-level properties they used to assert directly are now exercised through the hook, which is the surface a caller actually has: seed storage, mount, and read back what was restored and what was written. No property lost coverage, and re-running the mutation matrix against the moved tests is what caught two of them going soft, described above.

That refactor also removed a redundant filter. canonicalState used to call pickDeclared on its merge, but every path into it already carries filtered values -- setView filters a patch and parseViewState accepts only guard-passing fields -- so the call could not change any output, which is precisely why no test could observe it.

The spec moves with the code

docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md said the platform provides no view-state contract, and named useAppViewState specifically as a hook that does not exist. This PR falsifies that, and AGENTS.md requires the spec to change in the same commit, so it does.

The correction keeps a distinction the old text collapsed. URL-backed view position is still NOT an App SDK contract -- this PR adds no URL synchronisation -- so that section is retitled rather than deleted, and it now says why the two are complementary: a record restores a position on a fresh visit, which a URL cannot do, and a URL makes a position shareable, which a record cannot do. A new section describes what the record does cover.

The barrel sentence stayed true as written and was kept: the barrel really does export no useAppViewState, because the store is path-imported by design. The spec now says that explicitly rather than leaving it to imply the hook does not exist.

scripts/docs-lint.sh passes (260 files scanned).

An in-blob revision is new here, deliberately

There is no in-blob versioning precedent in this frontend. The two shapes already present are a version in the KEY (kc:file-explorer:state:v2) and a tolerant partial parse that never versions at all (issue-radar's loadUiState in lib/format.ts).

Neither fits a store the host owns on behalf of many apps. A version in the key makes the host's key FORMAT part of what every app has to know, and pushes a migration into each app's key string. One field inside the record lets the store decide centrally and keeps the key a stable address.

What is reported, and what is not

The design left this open. Split three ways, because the cases differ in what a reader can act on:

  • Scope mismatch: silent. It fires every time the user switches account. Logging routine behaviour trains everyone to ignore the channel.
  • Revision mismatch: one console.debug. Only reachable after someone deliberately changed the schema, and it answers "why did everyone's position reset" immediately.
  • Unreadable record: one console.warn, deduped per key. This is the only genuine fault -- something wrote garbage under a host-owned key -- and a warning repeated on every remount drowns out real signal.

Also

safeRemoveItem joins safeGetItem and safeSetItem in safeStorage.ts. It exposes behaviour the module already relied on internally (reclaimSpace calls removeItem); what was missing was a way for a caller outside that file to delete a key without hand-rolling the try/catch.

Nothing was added to the app-sdk/index.ts barrel. The store is imported by path, following the precedent recorded there for useComposerDraft: the barrel is the surface third-party apps resolve, held in agreement with the vendor stub, so a name on it is published, and publishing later is additive while un-publishing is a break. This store is builtin-only by construction, so in a third-party app it would be a hook that never persists anything.

4. What tests we did

265 tests pass across 10 files: the three new suites, both pre-existing aws-control suites unchanged, plus chatProtocolBoundary, appSdkProviderSplit, safeStorage and BuiltinAppRoute. tsc -b and eslint are clean.

Decision logic is kept in pure functions, following the shape of apps/overlaySlots.ts: viewStateKey, pickDeclared, parseViewState, isDefaultState, serializeViewRecord, and resolveViewStateWrite are all testable without mounting a tree.

Two behaviours worth naming, because the tests found them rather than confirming them:

  • isDefaultState({}) was false. A field that is simply not set IS its default, or the store would treat "nothing set" as a position worth storing.
  • Merely opening account A at the root DELETED account B's saved folder. The "remove when default" rule did not check whose record it was destroying. The write policy is now one pure function: a reset removes our own record, a real position overwrites whoever's was there, another scope's record is left alone, and a record that parses as nothing is removed regardless.

A third was found by mutation testing rather than by a failing test: the snapshot was keyed on scope alone, so a namespace granted LATE (AppHost forwards an installed app's origin from data, so the value above a continuously-mounted page can change) would leave the first namespace-less answer in place forever and the page would never restore. An appId change had the same shape as the scope bug -- it would write one app's position into another app's namespace. Both now re-read.

Mutation checks

Twelve guards, each deleted, each failing a named test, measured in one pass against a committed baseline with a clean working tree, verified clean and still at that commit afterwards. A thirteenth is disclosed below as NOT observable, rather than counted.

That discipline is load-bearing rather than tidiness. An earlier pass reverted mutations with git checkout -- while the same file still held unstaged edits, so the revert discarded three edits to viewState.ts and the following run measured a partially-reverted tree. A mutation result measured that way is not evidence, so those results were discarded rather than reported: the harness now commits a baseline first and every result below comes from that tree. The lost edits are the key re-read described above, which is also why it appears in the table.

Guard removed Named test that fails
useTrustedAppId gate replaced by raw identity neither reads nor writes for an external app
revision comparison falls back on a different revision
key re-read during render (covers appId, surface and scope) re-reads when the scope changes WITHOUT a remount
scope segment in the key keeps BOTH accounts' positions, one record each
scope percent-encoding encodes a scope so it cannot forge a segment boundary
remove-on-defaults removes the record when the user returns to the defaults
warn dedupe warns ONCE per key for a corrupt record
whole-record rejection rejects the WHOLE record when a declared field fails its guard
per-field validation in pickDeclared drops a declared field whose value fails its own guard
per-field validation on the way IN drops a DECLARED field whose value fails its guard
if (existing) return scoped in #8403's AppApiProvider keeps the host namespace when AppApiProvider is mounted inside the page
surface segment in the key gives two surfaces of ONE app two namespaces
surface-name charset validation refuses a parent traversal as a surface name

One guard is deliberately NOT in that table. The loop that serializes declared fields in a
stable order cannot be shown to matter today: with a single declared field, an ordered
serialization and a plain JSON.stringify of the merged object are byte-identical, so
deleting the loop breaks nothing and no honest test can be written for it. It is kept
because it is the last structural point at which an undeclared field cannot reach storage,
and because byte-stability is what isDefaultState compares -- both of which start to
matter at the second declared field. Reporting it as unobservable rather than padding the
count.

The last row crosses the stack boundary on purpose. The rule it targets is that identity is published only when there is none already in context, which stops a nested AppApiProvider from replacing a builtin page's identity with its external default. Without it, useTrustedAppId() returns null and this store falls back to defaults forever with no error anywhere -- the page just quietly stops remembering. The base pins that identity is not shadowed; this pins the consequence for the store, stated as the property rather than as whose code it is, so the assertion stays valid wherever the rule ends up living.

One nuance about the write filter, recorded because it changes what the guard actually is. Deleting the pickDeclared call inside canonicalization alone changes nothing: canonicalization only ever emits declared field names, so an undeclared field cannot reach storage even without it. The property is STRUCTURAL there rather than resting on that call. What is guard-dependent, and what the table's canonicalization and per-field rows pin, is removing the canonicalization entirely and validating each field's value.

Also worth recording: the first version of the late-namespace test passed for the wrong reason. Re-rendering from <Probe> to <Provider><Probe></Provider> changes the consumer's position in the tree, so React remounts it and the initializer re-runs -- the test passed whether or not the re-read existed. It now flips origin on a provider that stays mounted, which is a re-read and not a remount. Mutation testing is what surfaced that, and the lesson generalizes past the Suspense case: any rerender that moves a component's position manufactures a remount and can hide an initializer bug.

Warm-mount check

Because a first-render assertion can be masked by Suspense, there is an explicit test that mounts the consumer under a real React.lazy boundary twice: cold, where the child suspends before rendering, and then WARM, where the module is already resolved and parent and child render in one pass. The warm pass is the one nothing can hide behind, and the assertion is on the first recorded render rather than the settled DOM.

Served-bundle evidence

Built, synced into the directory the pod serves, and grepped over HTTP from the pod on an isolated port. Grepping string literals rather than identifier names, because minification mangles identifiers -- useAppViewState has zero hits in the served bundle and proves nothing either way.

From assets/AwsControlPage-DvGgAtrP.js (84924 bytes) fetched over HTTP:

Needle Hits
kc:app: 1
:view: 1
not a valid key segment 1
was written under another revision 1
could not be read; mounting with defaults 1
useAppViewState (identifier, negative control) 0
resolveViewStateWrite (identifier, negative control) 0
KEY_SEGMENT_RE (identifier, negative control) 0
kc:app:aws-control:view" (the OLD per-app key shape) 0

The composed key appears in the bundle as kc:app:${e}:view:${t}, so both segments are interpolated rather than one being dropped.

The served file is md5-identical to the freshly built one, so this is not a stale bundle.

Full-window screenshots

Attached. Captured from an isolated pod (port 7944, zero AWS accounts, zero prior sessions, onboarding wizard dismissed server-side). Token minted via kirocrew pod exec <pod> -- token, which bypasses the OWNER_UNPROVEN port-ownership refusal the pod token path hits from an agent sandbox.

The folder restore flow itself (descend, leave, return, land in subfolder) cannot be shown because the Drive pane is gated on drive?.exists, which requires a connected AWS account with an S3 bucket. That behaviour is covered by the 265 tests and 12 mutation guards above.

5. Any other suggestions on the work

Deliberately out of scope, listed so the boundaries are visible:

  • path is not added to the URL. Section 3's "URL first" preference is right for shareability, but a fresh visit to /aws-control from the sidebar carries no params, so the URL alone cannot solve the reported symptom. Complementary, and its own change.
  • The backup pane's "show remote archives" toggle is not persisted. It sits in a query key and widens a paid remote fetch, so restoring it would spend money on mount that the user did not ask to spend.
  • A folder deleted while you were away restores as an empty listing with stale breadcrumbs. Validating the prefix before the first render would need a round-trip, which defeats the no-skeleton goal.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 4, 2026 08:20
@chenmingwei23
chenmingwei23 requested review from buluoray and removed request for a team September 4, 2026 08:20
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 7dba54d89223cffef6a67c30206c604f95fb375b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence reviewed: the diff adds no new UI chrome or strings — it restores the drive's folder position across navigation (per account), the screenshots are sanity captures of existing surfaces, and breadcrumbs already orient a user restored into a deep folder. A restored-but-since-deleted prefix lands on the drive's existing empty-folder state with the breadcrumb as the way out, so no unrecoverable or confusing state is introduced. All failure paths (corrupt/old record) silently mount defaults, which is the correct user-facing behavior for a convenience restore.

UX-Verdict: PASS

Removes real memory excise — the drive now reopens in the folder you left, per account, with breadcrumbs already there to show where you landed.

[UX-REVIEWED] 7dba54d

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 7dba54d89223cffef6a67c30206c604f95fb375b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound host-owned store with structural scoping, but ~1MB of temp-screenshots/ binaries are committed while the description claims screenshots were not taken.

Watch

  • temp-screenshots/app-view-state-store/*.png (three files, ~1MB) is committed at the repo top level. The description's "Outstanding: full-window screenshots — Not done… Screenshots to be attached after Raymond supplies a token" contradicts the diff, which lands three partial captures in a directory no doc indexes. Once merged, the blobs live in public git history permanently — strip them (attach to the PR instead) before merge.
  • No cleanup story for orphaned scopes. The key shape (…:view:drive:<account-id>) means a disconnected AWS account leaves its record behind with nothing that can enumerate or reap it; the spec discloses the growth contract but not the orphan case. Fine at account cardinality, worth stating in the spec so the next consumer doesn't pick a scope where it isn't.

[DESIGN-REVIEWED] 7dba54d

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 7dba54d89223cffef6a67c30206c604f95fb375b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 7dba54d

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 7dba54d89223cffef6a67c30206c604f95fb375b: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 7dba54d89223cffef6a67c30206c604f95fb375b — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 7dba54d

Verdict parsed from the review's SHA-scoped output markers for commit 7dba54d89223cffef6a67c30206c604f95fb375b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 7dba54d89223cffef6a67c30206c604f95fb375b: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 7dba54d89223cffef6a67c30206c604f95fb375b — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All counts verified. Writing the review.

First-Principles-Verdict: CONCERNS

A 479-line generalized store ships for one string field, while usePersistedString — imported four lines above the new import — already restores a scoped string on first render.

What this change ships

Intent: coming back to a builtin app page should land where you left it — the drive's folder, per account. ADDITION.

  1. Drive reopens in the folder you left, per account — justified (named harm: root + skeleton on every return)
  2. New useAppViewState store with declaration/guard/defaults machinery — one consumer, generalized
  3. revision record field with mismatch-discard path — one consumer, always 1, inherited ("so we can later")
  4. scope option with percent-encoded key segment and re-read on change — one consumer; the re-read is its one real edge over the existing hook
  5. Builtin-only gate via useTrustedAppId — guards a caller that cannot exist (module is off the barrel/vendor stub; 0 external import paths)
  6. Record deleted when position returns to defaults — undeclared, minor
  7. Console debug/warn tiers with once-per-key ledger — undeclared
  8. safeRemoveItem in safeStorage — one consumer (viewState.ts)
  9. Spec rewrite in same commit — mandated by AGENTS.md, justified
  10. Three PNGs under temp-screenshots/ — repo convention (800+ existing files)

Watch

  • "The platform had nowhere to put that state" is contradicted by the same hunk: DrivePage.tsx keeps usePersistedString (4 consumer files) for the selected account, and usePersistedString('awsControl.drive.path.'+account, '') removes the reported harm — synchronous first-render read, per-account isolation via the key. The honest gap: DriveSectionView mounts unkeyed (AwsControlPage.tsx:823), and usePersistedString's effect writes the old value under a changed key, so the swap needs key={account} there. Two lines versus 479 + 840 of tests; the store's other differentiators have zero exercised consumers until Convert AWS Control's remaining query sites to the host-namespaced key #8401 lands.
  • The useTrustedAppId gate defends a namespace no untrusted party can reach: external apps resolve only the vendor barrel, which deliberately omits this module. It costs little, but its justification is entirely "when this is published later" — inherited.

Subtractions

  • Defer viewState.ts to Convert AWS Control's remaining query sites to the host-namespaced key #8401 (its first multi-surface consumer); ship this PR as usePersistedString(\awsControl.drive.path.${account}`, '')pluskey={account}atAwsControlPage.tsx:823`.
  • If the store stays: drop revision (one consumer, always 1). An absent field already reads as mismatch→defaults, so adding it at the first real schema change degrades identically — the author's "cannot migrate later" argument holds for name, not revision.
  • If the store stays: drop the ViewStateOutcome/reportViewOutcome/unreadableLogged tier (~60 lines + 5 tests) — console output no user acts on; keep at most the corrupt-record warn inline.

[FIRST-PRINCIPLES-REVIEWED] 7dba54d

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from efa6f2b to 3969810 Compare September 4, 2026 08:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from 3969810 to d48c6bd Compare September 4, 2026 08:39
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fixed in d48c6bd6c, and the finding was correct including the part about how it would have failed.

I reproduced the sequence against the real functions before changing anything. With the per-app key, the drive reads back the library's record, finds path merely ABSENT, falls back to the default, and reports outcome restored:

drive wrote:      {"revision":1,"scope":"acct","state":{"path":"docs"}}
library wrote:    {"revision":1,"scope":"acct","state":{"sort":"name"}}
drive reads back: {"state":{"path":""},"outcome":"restored"}

So it was not merely a clobber -- it was a clobber that reported success, which is the one shape neither consumer's own suite could ever catch.

ViewStateDecl now carries a required name and the key is kc:app:<appId>:view:<name>. Taking your recommendation to do it in this PR: the in-blob revision cannot express a key-shape change, so the window where this is free is exactly now, while the format has zero installed records.

A key segment rather than several sections in one record, for a reason worth stating: a shared record has to be read-modify-written by every consumer, which is the shape that lets one writer clobber a field it holds a stale copy of. Separate keys give each surface an independent lifetime, so a corrupt record on one pane cannot take another pane's position with it.

name is validated on the same charset as appId and throws rather than sanitizing, following AppScopedApiProvider's precedent in this layer -- it is authored in code as part of a module-level declaration and is never data, so a bad one should fail identically on every machine.

Three tests pin it, and both new guards are mutation-checked (delete the segment from the key, or the charset check, and a named test fails):

  • gives two surfaces of ONE app two namespaces
  • keeps the first surface readable after the second writes
  • would have reported a clobber as a successful restore -- kept deliberately as the record of why the segment exists
  • plus eight refusals for ., .., ../file-explorer, separators, uppercase and a space

I did not take the alternative you offered (an asserted one-decl-per-app rule), because it constrains apps for the store's convenience and would fire at runtime in production rather than at author time.

257 tests green across 9 files; 13 mutation guards each failing a named test.

@chenmingwei23
chenmingwei23 force-pushed the feat/builtin-app-identity-seam branch from 9f9b0c6 to 5442b13 Compare September 4, 2026 08:42
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch 2 times, most recently from 57bb8a1 to dafc5ac Compare September 4, 2026 08:57
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Three items. One is a real defect and is fixed; one is a fair reduction and is taken; the two subtractions I am declining, and one of them rests on a premise I can show is false.

Fixed: the spec contradiction

Correct and load-bearing. docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md named useAppViewState specifically as a hook that does not exist, and AGENTS.md:221 requires the spec to change in the same commit. Updated in dafc5ac57, scripts/docs-lint.sh green.

The correction keeps a distinction the old text collapsed: URL-backed view position is still NOT an App SDK contract, because this PR adds no URL synchronisation, so that section is retitled rather than deleted and now states why the two are complementary. One sentence I deliberately kept as-is: the barrel really does export no useAppViewState. That was accurate before and still is, because the store is path-imported; the spec now says so explicitly instead of leaving it to imply the hook does not exist.

Taken: the test-only export

__resetViewStateLogsForTest is gone from the shipped module. The dedupe test now uses its own appId, so its key is unique to it and the ledger stays private. Exported surface drops by one, and production code no longer carries a function that exists only so a test can call it.

Declining: drop revision

The stated reason is that "a future meaning-change already lands on the same defaults path via a failed field guard". That holds only for a change that also changes the field's TYPE. The case revision exists for is a same-type meaning change, which no guard can see:

path is 'a/b' today. Suppose it later becomes percent-encoded, or gains a leading slash, or becomes bucket-relative rather than section-relative. Every one of those is still a string, so isViewString passes, parseViewState reports restored, and the drive opens a folder the user was never in -- silently. That is strictly worse than the "one reset folder" the finding prices it at, because a wrong restore is indistinguishable from a correct one.

Worth noting the reviewer's own Watch item makes the same point in the other direction: it observes that a key-shape change cannot be expressed by revision. Correct -- and symmetrical. A revision cannot version the key, and the key cannot version the contents. Both exist because neither substitutes for the other; Design Review had me add the surface segment for exactly that reason in the previous round.

revision: 1 being the only variant ever constructed is what a version field looks like before its first bump. The cost of carrying it is one integer and one comparison; the cost of adding it later is that every installed record predates it and cannot be told apart.

Declining: drop the reporting tiers

Two reasons, neither of them mine.

The revision-mismatch debug line was decided deliberately, not by default: a mismatch is only reachable after someone changes the schema on purpose, and one debug line there answers "why did everyone's position reset" immediately. That is why the tiers are split at all rather than uniformly silent -- a scope mismatch IS silent, precisely because it is routine and logging it would train people to ignore the channel.

The unreadable warn plus its once-per-key ledger is the pattern this layer already established: useTrustedAppId keeps refusedNamespaceLogged for exactly the same reason, that a refused capability is invisible otherwise. If "nobody is reading this channel" is the standard, it retires that one too, and the argument belongs there rather than here.

On the shape of the whole thing

The one-liner alternative is fairly described and I am not going to pretend otherwise: usePersistedString('awsControl.drive.path.<account>', '') does read in a useState initializer, and for this one consumer it would remove the named harms.

But that is the nineteenth app-local remedy, and this design exists because the eighteen before it could not be reused. The premise recorded in the design is that of nineteen apps that issue queries, exactly one had solved this, in app-local code no other app could reuse -- and the conclusion drawn was that the capability is missing from the platform, not from the apps. A per-account key in AWS Control's own namespace is that same shape again, and it leaves the next app to rediscover the scope trap, the first-render timing, and the write filter on its own.

The single-consumer count is real and I would not argue with it as an observation. It is what PR 2 of 3 looks like: PR 3 consumes the same identity seam for cache retention in parallel, and the two were split precisely so each stays reviewable.

257 tests green across 9 files; 13 mutation guards each failing a named test, measured from one committed baseline.

@github-actions github-actions Bot removed the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 4, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Bundle size, measured against this PR's own base

Recording this pre-emptively because a Bundle Size Gate failure on a merge ref proves nothing about a diff on its own, and this store does add a module to the app-sdk path -- so it was worth measuring rather than assuming.

Built both sides with npm run build and compared byte counts and content hashes.

The chunk that trips the gate is untouched by this diff. t-BLZeayKy.js is 755,868 bytes with the same content hash on both sides -- base 5442b1315 and this head. At 738.15 KB it is also UNDER the 740 KB ceiling in website/scripts/check-bundle-size.mjs (t: 740 * KB, // measured 702 KB), so a build of this branch alone does not breach it. The reported breach is 757,764 B = 740.00 KB, which is 4 bytes over and belongs to a tree this branch does not produce.

The gate passes on this branch. vite build --mode analyze then node scripts/check-bundle-size.mjs:

bundle-size gate: 804 chunks within budget (default 500.0 KB, 14 allowlisted).

exit 0.

What this diff does cost, for completeness:

base 5442b1315 this head delta
total across 796 chunks 39,464,592 B 39,468,035 B +3,443 B
AwsControlPage 81,858 B 84,924 B +3,066 B
safeStorage 1,085 B 1,174 B +89 B
t (the gate's chunk) 755,868 B 755,868 B 0 B, same hash

AwsControlPage at 82.93 KB has no budget entry, so it takes the 500 KB default and uses 17% of it. Chunk count is unchanged at 796.

One side effect worth disclosing. A shared chunk is renamed: base emits AwsConsentGate-BWp6SYES.js (103,217 B) where this head emits identity-1Ahmu06B.js (103,709 B). Importing app-sdk/identity from the store changes which module the shared chunk is named after. It is benign for the gate -- neither name is a CHUNK_BUDGETS entry, so both take the 500 KB default, and no budget entry is left matching nothing (which would otherwise emit the gate's matched no emitted chunk warning). Flagging it because a chunk rename is the kind of thing that looks like noise until a budget entry happens to name the chunk that moved.

Both builds are reproducible: rebuilding this head a second time produced a byte-identical manifest.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from dafc5ac to cdde9c5 Compare September 4, 2026 09:27
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Taken, in c0f4d5de9. Your keep-list is now exactly the exported surface.

I went looking for a precedent to defend the exports and did not find one: readPersistedString was my candidate, and it has a real non-test consumer at RemoteCrewPanel.tsx:65, so it argues the opposite of what I wanted it to. Your count was right and mine was wishful.

pickDeclared, isDefaultState, serializeViewRecord, parseViewState and resolveViewStateWrite are module-internal now, with the three types that only described their signatures. Exported: useAppViewState, viewStateKey, isViewString, ViewStateDecl, ViewStateSetter.

The record-level properties moved to the hook, which is the surface a caller has: seed storage, mount, read back what was restored and what was written.

Re-running the mutation matrix is what made this worth doing

Moving those tests made three guards stop being observable, which I would not have known from a green suite:

  • Whole-record rejection went vacuous because skipping a bad field also yields the defaults. The state assertion could not tell the two apart. The discriminating signal is the reporting tier -- rejection warns, a skip reports restored and says nothing -- so the test now asserts the warn. A corrupt record silently passing as "no saved position" is exactly the failure it should catch.
  • Per-field write validation went vacuous because my test passed an UNDECLARED field, which pickDeclared drops by iteration regardless of the guard. Added the case that actually needs it: a declared field carrying the wrong type.
  • Canonicalization went vacuous for a real reason, and the answer was to delete code. canonicalState called pickDeclared on its merge, but every path in already carries filtered values, so the call could not change any output -- which is why nothing could observe it. Removed.

One guard I am now reporting as NOT observable instead of counting it: the loop that serializes declared fields in stable order. With one declared field, ordered and plain serialization are byte-identical, so deleting it breaks nothing and no honest test exists. I kept it because it is the last structural point at which an undeclared field cannot be serialized, and because byte-stability is what the defaults comparison relies on -- both begin to matter at the second declared field. The count is twelve observable guards, not thirteen.

revision is still with the maintainer

Not re-arguing it -- I conceded the mechanism in the previous round after testing your claim, and the only thing keeping it in the diff is that it is mandated by the approved design and I was instructed to implement it as specified. Removing a mandated element is the maintainer's call. If he agrees with you it is a small deletion, and I will take it in one push.

265 tests green across 10 files, typecheck / eslint / docs-lint clean.

@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from c0f4d5d to 95de3ae Compare September 4, 2026 10:11
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from 95de3ae to abb85ac Compare September 4, 2026 10:32
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 4, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

viewStateKey unexported in abb85ac18 -- and unexporting it made the tests better, not just smaller. They now assert the key as a literal string, which states the on-disk contract; recomputing it with the derivation under test had been quietly circular, since a dropped segment would have agreed with its own test. Four names exported now.

The three remaining subtractions all reduce to decisions the maintainer has already made explicitly, so I am dispositioning them rather than either taking them unilaterally or arguing further.

The reporting tier is not independent. You would keep the corrupt-record console.warn either way, and the revision-mismatch debug tier goes with the revision field. So this collapses into the revision question rather than standing as its own item -- if that field goes, the tier goes with it in the same edit, and ViewSnapshot.outcome narrows to two cases.

revision is unchanged from the previous round: I conceded the mechanism after testing your claim, and the only thing holding it in the diff is that the approved design mandates it and I was instructed to implement it as specified. Awaiting the ruling.

Remove-on-default is a real trade, and I would resolve it the other way -- though it is also safeRemoveItem's only consumer, and that helper was added at the maintainer's explicit direction, so removing it reverses that too.

Where I think the reasoning goes wrong is the equivalence with usePersistedString's residue. That comparison held when a single record was tagged with one scope. It does not hold now that the scope is in the key: without removal, every account a user merely OPENS leaves a permanent row, so the rows grow with accounts visited rather than with positions held. The PR states a growth contract in the spec -- scope names a subject the user chooses among -- and removal is what keeps that contract tight. Rows existing only while there is somewhere to return to is a cleaner invariant than rows existing for every scope ever rendered.

Two things I will concede about it. It is hygiene, not correctness: without removal a stale or corrupt record is overwritten with a defaults record instead of deleted, which discards the bad data just as effectively, and the user lands at the root either way. And inheriting usePersistedString's looseness is an option -- it is just not obviously the better one, and it is not mine to choose here.

265 tests green across 10 files; 12 observable mutation guards each failing a named test, from one committed baseline, with the thirteenth reported as unobservable rather than counted.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 4, 2026
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
A builtin page is unmounted on navigation, so its component state returns to
defaults on every visit. AWS Control's drive is the reported case: descend into
a folder, leave, come back, and you are at the bucket root again.

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request.
@chenmingwei23
chenmingwei23 force-pushed the feat/app-view-state-store branch from abb85ac to 7dba54d Compare September 4, 2026 12:50
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 merged commit f83052d into feat/builtin-app-identity-seam Sep 4, 2026
25 of 27 checks passed
@chenmingwei23
chenmingwei23 deleted the feat/app-view-state-store branch September 4, 2026 15:35
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 4, 2026
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
Two halves of one symptom, both standing on the identity seam in the previous
commit. Leaving a builtin app page unmounts it, so the page returns to its
defaults AND the data it was showing is collected shortly after. Coming back
lands at the top level, behind loading placeholders. AWS Control's drive is the
reported case: descend into a folder, leave, come back, and you are at the
bucket root again with a skeleton.

VIEW STATE (#8407)

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

CACHE RETENTION (#8404)

react-query's 5-minute default gcTime collects an unmounted page's data. One app
of nineteen was exempt, and only because it fixed this in app-local code no
other app could reuse: issue-radar called setQueryDefaults(['issue-radar'],
{ gcTime }) at module scope.

Give the host the same one-liner, per app. BuiltinAppRoute already knows which
app owns the route before the page's first query mounts, so a sibling ahead of
its Suspense boundary reads the appId from the identity context and registers
retention for the [appId] key prefix. react-query matches query defaults by
prefix, so that one registration covers every key the app already writes by hand
-- AWS Control's accounts, drive and costs queries are all under
['aws-control', ...], so the reported symptom is fixed with no change to the app.

Registered from a render body, not an effect, for the same reason the view-state
read is: an effect version passes a cold-load test and still fails a user, since
on a repeat visit React renders parent and child in one pass.

useAppQuery is the other half: it takes the appId from context and prefixes the
key, so an app cannot forge its own namespace, and code that wants the HOST's
cache stays on plain useQuery where the difference is greppable. The prefix is
exactly the appId and not ['app', appId, ...], which is the only shape that also
leaves every existing key untouched: five prefixes are shared between an app and
the host deliberately -- artifact, awsConsent, apps, pull-request-source, and
workflow-definitions, whose rename would split the workflow cards in chat off
the list in the app. What changes is who authors the prefix, not what it is.

Two call sites converted, deliberately mixed in opposite directions: UsagePane's
costs query is host-built while its invalidation stays a hand-written literal,
and its drive invalidation is host-built while the query stays a literal. Either
mismatch would stop a consent grant refreshing what it changed, so the pane
exercises the byte-identity claim instead of asserting it. The remaining 33
sites follow separately (#8401).

issue-radar's own registration is deleted, because the host now covers that exact
prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed
by the hashed key, so both wrote the same entry and the last writer won --
decided by whether the app's lazy chunk had evaluated yet. Identical values hid
it; a future change to either number would not have.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request; then retention refusals for a host page and an external origin, keys
byte-identical to the literals they replace in both directions, degrade to a
plain query with no namespace, already-prefixed keys collapsing with one warning,
one registration per client and app, cold AND warm ordering each self-contained,
the namespace surviving both api-layer providers nested inside a builtin page --
the shape spec-builder and IncidentChat use, where a shadowed identity would put
an app's data outside the very namespace being retained with no error to see --
and an A/B on a faked clock showing the data present at six minutes and
collected at thirty with an identity, gone at six without one, which is the
reported symptom reproduced.

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
Two halves of one symptom, both standing on the identity seam in the previous
commit. Leaving a builtin app page unmounts it, so the page returns to its
defaults AND the data it was showing is collected shortly after. Coming back
lands at the top level, behind loading placeholders. AWS Control's drive is the
reported case: descend into a folder, leave, come back, and you are at the
bucket root again with a skeleton.

VIEW STATE (#8407)

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

CACHE RETENTION (#8404)

react-query's 5-minute default gcTime collects an unmounted page's data. One app
of nineteen was exempt, and only because it fixed this in app-local code no
other app could reuse: issue-radar called setQueryDefaults(['issue-radar'],
{ gcTime }) at module scope.

Give the host the same one-liner, per app. BuiltinAppRoute already knows which
app owns the route before the page's first query mounts, so a sibling ahead of
its Suspense boundary reads the appId from the identity context and registers
retention for the [appId] key prefix. react-query matches query defaults by
prefix, so that one registration covers every key the app already writes by hand
-- AWS Control's accounts, drive and costs queries are all under
['aws-control', ...], so the reported symptom is fixed with no change to the app.

Registered from a render body, not an effect, for the same reason the view-state
read is: an effect version passes a cold-load test and still fails a user, since
on a repeat visit React renders parent and child in one pass.

useAppQuery is the other half: it takes the appId from context and prefixes the
key, so an app cannot forge its own namespace, and code that wants the HOST's
cache stays on plain useQuery where the difference is greppable. The prefix is
exactly the appId and not ['app', appId, ...], which is the only shape that also
leaves every existing key untouched: five prefixes are shared between an app and
the host deliberately -- artifact, awsConsent, apps, pull-request-source, and
workflow-definitions, whose rename would split the workflow cards in chat off
the list in the app. What changes is who authors the prefix, not what it is.

Two call sites converted, deliberately mixed in opposite directions: UsagePane's
costs query is host-built while its invalidation stays a hand-written literal,
and its drive invalidation is host-built while the query stays a literal. Either
mismatch would stop a consent grant refreshing what it changed, so the pane
exercises the byte-identity claim instead of asserting it. The remaining 33
sites follow separately (#8401).

issue-radar's own registration is deleted, because the host now covers that exact
prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed
by the hashed key, so both wrote the same entry and the last writer won --
decided by whether the app's lazy chunk had evaluated yet. Identical values hid
it; a future change to either number would not have.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request; then retention refusals for a host page and an external origin, keys
byte-identical to the literals they replace in both directions, degrade to a
plain query with no namespace, already-prefixed keys collapsing with one warning,
one registration per client and app, cold AND warm ordering each self-contained,
the namespace surviving both api-layer providers nested inside a builtin page --
the shape spec-builder and IncidentChat use, where a shadowed identity would put
an app's data outside the very namespace being retained with no error to see --
and an A/B on a faked clock showing the data present at six minutes and
collected at thirty with an identity, gone at six without one, which is the
reported symptom reproduced.

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
Two halves of one symptom, both standing on the identity seam in the previous
commit. Leaving a builtin app page unmounts it, so the page returns to its
defaults AND the data it was showing is collected shortly after. Coming back
lands at the top level, behind loading placeholders. AWS Control's drive is the
reported case: descend into a folder, leave, come back, and you are at the
bucket root again with a skeleton.

VIEW STATE (#8407)

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

CACHE RETENTION (#8404)

react-query's 5-minute default gcTime collects an unmounted page's data. One app
of nineteen was exempt, and only because it fixed this in app-local code no
other app could reuse: issue-radar called setQueryDefaults(['issue-radar'],
{ gcTime }) at module scope.

Give the host the same one-liner, per app. BuiltinAppRoute already knows which
app owns the route before the page's first query mounts, so a sibling ahead of
its Suspense boundary reads the appId from the identity context and registers
retention for the [appId] key prefix. react-query matches query defaults by
prefix, so that one registration covers every key the app already writes by hand
-- AWS Control's accounts, drive and costs queries are all under
['aws-control', ...], so the reported symptom is fixed with no change to the app.

Registered from a render body, not an effect, for the same reason the view-state
read is: an effect version passes a cold-load test and still fails a user, since
on a repeat visit React renders parent and child in one pass.

useAppQuery is the other half: it takes the appId from context and prefixes the
key, so an app cannot forge its own namespace, and code that wants the HOST's
cache stays on plain useQuery where the difference is greppable. The prefix is
exactly the appId and not ['app', appId, ...], which is the only shape that also
leaves every existing key untouched: five prefixes are shared between an app and
the host deliberately -- artifact, awsConsent, apps, pull-request-source, and
workflow-definitions, whose rename would split the workflow cards in chat off
the list in the app. What changes is who authors the prefix, not what it is.

Two call sites converted, deliberately mixed in opposite directions: UsagePane's
costs query is host-built while its invalidation stays a hand-written literal,
and its drive invalidation is host-built while the query stays a literal. Either
mismatch would stop a consent grant refreshing what it changed, so the pane
exercises the byte-identity claim instead of asserting it. The remaining 33
sites follow separately (#8401).

issue-radar's own registration is deleted, because the host now covers that exact
prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed
by the hashed key, so both wrote the same entry and the last writer won --
decided by whether the app's lazy chunk had evaluated yet. Identical values hid
it; a future change to either number would not have.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request; then retention refusals for a host page and an external origin, keys
byte-identical to the literals they replace in both directions, degrade to a
plain query with no namespace, already-prefixed keys collapsing with one warning,
one registration per client and app, cold AND warm ordering each self-contained,
the namespace surviving both api-layer providers nested inside a builtin page --
the shape spec-builder and IncidentChat use, where a shadowed identity would put
an app's data outside the very namespace being retained with no error to see --
and an A/B on a faked clock showing the data present at six minutes and
collected at thirty with an identity, gone at six without one, which is the
reported symptom reproduced.

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
chenmingwei23 added a commit that referenced this pull request Sep 5, 2026
Two halves of one symptom, both standing on the identity seam in the previous
commit. Leaving a builtin app page unmounts it, so the page returns to its
defaults AND the data it was showing is collected shortly after. Coming back
lands at the top level, behind loading placeholders. AWS Control's drive is the
reported case: descend into a folder, leave, come back, and you are at the
bucket root again with a skeleton.

VIEW STATE (#8407)

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

CACHE RETENTION (#8404)

react-query's 5-minute default gcTime collects an unmounted page's data. One app
of nineteen was exempt, and only because it fixed this in app-local code no
other app could reuse: issue-radar called setQueryDefaults(['issue-radar'],
{ gcTime }) at module scope.

Give the host the same one-liner, per app. BuiltinAppRoute already knows which
app owns the route before the page's first query mounts, so a sibling ahead of
its Suspense boundary reads the appId from the identity context and registers
retention for the [appId] key prefix. react-query matches query defaults by
prefix, so that one registration covers every key the app already writes by hand
-- AWS Control's accounts, drive and costs queries are all under
['aws-control', ...], so the reported symptom is fixed with no change to the app.

Registered from a render body, not an effect, for the same reason the view-state
read is: an effect version passes a cold-load test and still fails a user, since
on a repeat visit React renders parent and child in one pass.

useAppQuery is the other half: it takes the appId from context and prefixes the
key, so an app cannot forge its own namespace, and code that wants the HOST's
cache stays on plain useQuery where the difference is greppable. The prefix is
exactly the appId and not ['app', appId, ...], which is the only shape that also
leaves every existing key untouched: five prefixes are shared between an app and
the host deliberately -- artifact, awsConsent, apps, pull-request-source, and
workflow-definitions, whose rename would split the workflow cards in chat off
the list in the app. What changes is who authors the prefix, not what it is.

Two call sites converted, deliberately mixed in opposite directions: UsagePane's
costs query is host-built while its invalidation stays a hand-written literal,
and its drive invalidation is host-built while the query stays a literal. Either
mismatch would stop a consent grant refreshing what it changed, so the pane
exercises the byte-identity claim instead of asserting it. The remaining 33
sites follow separately (#8401).

issue-radar's own registration is deleted, because the host now covers that exact
prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed
by the hashed key, so both wrote the same entry and the last writer won --
decided by whether the app's lazy chunk had evaluated yet. Identical values hid
it; a future change to either number would not have.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request; then retention refusals for a host page and an external origin, keys
byte-identical to the literals they replace in both directions, degrade to a
plain query with no namespace, already-prefixed keys collapsing with one warning,
one registration per client and app, cold AND warm ordering each self-contained,
the namespace surviving both api-layer providers nested inside a builtin page --
the shape spec-builder and IncidentChat use, where a shadowed identity would put
an app's data outside the very namespace being retained with no error to see --
and an A/B on a faked clock showing the data present at six minutes and
collected at thirty with an identity, gone at six without one, which is the
reported symptom reproduced.

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
bolichen97 pushed a commit that referenced this pull request Sep 8, 2026
Two halves of one symptom, both standing on the identity seam in the previous
commit. Leaving a builtin app page unmounts it, so the page returns to its
defaults AND the data it was showing is collected shortly after. Coming back
lands at the top level, behind loading placeholders. AWS Control's drive is the
reported case: descend into a folder, leave, come back, and you are at the
bucket root again with a skeleton.

VIEW STATE (#8407)

Add a host-owned view-state store. An app declares the few coordinates worth
restoring; the host owns the key (kc:app:<appId>:view, appId from the identity
context) and decides what happens when a record cannot be read back.

The declaration is a filter, not documentation: pickDeclared runs on every
write, so a field the app did not declare cannot reach storage. Passing a whole
component state in persists only the coordinates, which is what makes 'do not
persist the drive's contents' an enforced property.

The record is read in a useState initializer, so the restored value exists on
the consumer's first render and the drive's infinite query is keyed to the right
folder from its first request -- no wasted root listing, no skeleton flash.
Reading in an effect would look correct on a cold visit, because a React.lazy
child is still suspended while its parent commits, and be a render too late on
the repeat visit this feature exists to serve.

scope is first-class: a prefix means nothing outside the bucket it was taken in,
so a mismatched scope resolves to the defaults through the same path as a
mismatched revision, and a scope change re-reads during render rather than
carrying a position forward under a new label.

CACHE RETENTION (#8404)

react-query's 5-minute default gcTime collects an unmounted page's data. One app
of nineteen was exempt, and only because it fixed this in app-local code no
other app could reuse: issue-radar called setQueryDefaults(['issue-radar'],
{ gcTime }) at module scope.

Give the host the same one-liner, per app. BuiltinAppRoute already knows which
app owns the route before the page's first query mounts, so a sibling ahead of
its Suspense boundary reads the appId from the identity context and registers
retention for the [appId] key prefix. react-query matches query defaults by
prefix, so that one registration covers every key the app already writes by hand
-- AWS Control's accounts, drive and costs queries are all under
['aws-control', ...], so the reported symptom is fixed with no change to the app.

Registered from a render body, not an effect, for the same reason the view-state
read is: an effect version passes a cold-load test and still fails a user, since
on a repeat visit React renders parent and child in one pass.

useAppQuery is the other half: it takes the appId from context and prefixes the
key, so an app cannot forge its own namespace, and code that wants the HOST's
cache stays on plain useQuery where the difference is greppable. The prefix is
exactly the appId and not ['app', appId, ...], which is the only shape that also
leaves every existing key untouched: five prefixes are shared between an app and
the host deliberately -- artifact, awsConsent, apps, pull-request-source, and
workflow-definitions, whose rename would split the workflow cards in chat off
the list in the app. What changes is who authors the prefix, not what it is.

Two call sites converted, deliberately mixed in opposite directions: UsagePane's
costs query is host-built while its invalidation stays a hand-written literal,
and its drive invalidation is host-built while the query stays a literal. Either
mismatch would stop a consent grant refreshing what it changed, so the pane
exercises the byte-identity claim instead of asserting it. The remaining 33
sites follow separately (#8401).

issue-radar's own registration is deleted, because the host now covers that exact
prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed
by the hashed key, so both wrote the same entry and the last writer won --
decided by whether the app's lazy chunk had evaluated yet. Identical values hid
it; a future change to either number would not have.

Tests: the write filter, every parse rejection, the builtin gate, first-render
publication, the scope re-read, the three reporting tiers, and the drive's first
request; then retention refusals for a host page and an external origin, keys
byte-identical to the literals they replace in both directions, degrade to a
plain query with no namespace, already-prefixed keys collapsing with one warning,
one registration per client and app, cold AND warm ordering each self-contained,
the namespace surviving both api-layer providers nested inside a builtin page --
the shape spec-builder and IncidentChat use, where a shadowed identity would put
an app's data outside the very namespace being retained with no error to see --
and an A/B on a faked clock showing the data present at six minutes and
collected at thirty with an identity, gone at six without one, which is the
reported symptom reproduced.

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
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.

1 participant