feat(apps): keep a builtin app's cached data across leaving its page - #8404
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Invisible-by-design change — returning to an app now repaints from cache instead of skeletons; no new user-facing surface, strings, or controls. Suggestions
[UX-REVIEWED] badab7b |
Design Review (Fable 5) — ✅ PASSDesign-level review of All hunks map to the stated purpose, alternatives were explicitly weighed (global Design-Verdict: PASS Right seam, byte-identical keys, gated namespace, per-app reversibility — the alternatives were weighed on the record and the residual gaps are named, tested, or tracked. [DESIGN-REVIEWED] badab7b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks done — the review contract, intent, patch, and repo evidence are gathered; consumer counts verified by grep. Final review: First-Principles-Verdict: CONCERNS The retention seam alone is the fix; the What this change shipsIntent: returning to a builtin app should repaint from cached data instead of skeletons — a FIX (reported on AWS Control), shipped with a declared ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] badab7b |
c2f934b to
05abfe4
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
05abfe4 to
df1c43c
Compare
df1c43c to
3a4ab16
Compare
|
Both First Principles concerns are fixed in issue-radar's duplicate registration is deleted, and it was worse than redundant -- the PR body had this wrong.
|
|
On the module-scope loop over What does not hold: I assumed startup-wide registration would leak app retention onto host queries sitting under an app-shaped prefix. Checked every appId in the registry -- no host query does. The only apparent hit, Two things that do:
The test apparatus reads as the most mis-fixable part of the diff, and I think it is the opposite: it is what stops the mechanism being quietly moved into an effect later. Its first version WAS vacuous -- order-dependent warmth plus a fallback assertion that held whether or not the tree suspended -- and it has been rewritten so each case establishes its own state and asserts positively which path it took. Recorded in section 5 so the option is on file rather than lost. |
f88d437 to
1b6dd1f
Compare
|
Both subtractions considered in Taken -- Held -- folding The approved design names this directly: "Keep More concretely: Held -- the already-prefixed warning in On the contingency point -- if #8401 stalls, the namespacing half is permanent surface with one consumer. Accepted as stated. It is the honest risk of the staging, and the reason the conversion is a filed issue with a named scope rather than a vague intention. For the record on process: still one commit, amended rather than added, per the two-commit gate. |
141e524 to
fbe751a
Compare
fbe751a to
ea46193
Compare
|
Checking these two claims corrected an error of mine that had been in the body since the first push, in this PR's favour. "The apps it exists to onboard are barred from adopting it" is not right, and neither was my no-op list. I had written that retention is a no-op for So 12 apps and 193 keys are covered with no change to any app, not just AWS Control. The real no-op set is three apps, and only Accepted -- the ratchet, not the hook, is what stops an in-tree query claiming another app's namespace. Correct, and the body now says so: the hook makes the prefix host-authored for the code that calls it, and the ownership ratchet is the tree-wide invariant. My "cannot forge" phrasing overstated the hook's reach. Held -- the |
Leaving a builtin app page unmounts it, and react-query's 5-minute default
gcTime then collects the data it was showing. Return after a coffee and the
page repaints from nothing behind loading placeholders, once per visit. 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. An effect version passes a
cold-load test and still fails a user: on a cold load the page module is
being fetched, so Suspense holds the child back until after the parent's
effects have run and the ordering bug is invisible. On a REPEAT visit the
module is already loaded, React renders parent and child in one pass, and an
effect is a render too late -- which is the visit this exists for.
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, both in a file no parallel branch is editing, and
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), to keep off files another branch holds.
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: 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 collapse and warn once;
one registration per client and app; cold AND warm ordering, each
self-contained so neither depends on test order; 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.
Thirteen mutations checked, each failing a named test.
ea46193 to
badab7b
Compare
412780f
into
feat/builtin-app-identity-seam
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).
1. What is the problem?
Leave a builtin app page and the dashboard unmounts it. React Router drops the routed element, so the component's state resets, and react-query's default
gcTimeof 5 minutes then collects the data the page was showing. Come back after a short absence and the page rebuilds from nothing: loading placeholders first, then a fresh round of requests for data that has not changed.This is platform-wide rather than a defect in any one app. Of the 19 builtin apps that issue queries, exactly one is exempt, and only because it solved the problem in app-local code no other app can reuse:
apps/issue-radar/IssueRadarPage.tsx:37callssetQueryDefaults(['issue-radar'], { gcTime: CACHE_RETENTION_MS })at module scope. The other 18 have no mechanism to reach for.There is a second, quieter problem underneath it. An app page cannot say which keys in the shared query cache are its own. Without that, there is nothing for retention -- or for anything else scoped to an app -- to attach to.
2. Why this issue matters to the user
An app that repaints from scratch on every visit cannot be a place you return to. It pushes you toward one long-lived session and charges you for navigating, which is the opposite of what a dashboard made of many small apps is for. The cost lands on the backend as well: unchanged data is re-fetched once per visit.
AWS Control is the reported case. Its accounts, drive listing and cost figures all sit in the cache under
['aws-control', ...], so a six-minute detour into chat and back means three skeletons and three round trips for a bill that has not moved.Visual evidence (isolated pod, port 8009)
Captured from an isolated pod running this branch's built bundle (
appQuery-onDNC_pg.jsconfirmed in the serveddist/assets). The pod has no AWS accounts, so the page renders its empty state -- which still exercises the react-query cache path (the accounts/drive/costs queries fire and return empty arrays; cache retention keeps those results across unmount).1. AWS Control loaded -- the app's Accounts page after initial navigation:
2. Navigated away to chat -- left the app, which unmounts the page component:
3. Returned to AWS Control -- navigated back; the page painted immediately from cached query data with no loading skeletons:
The absence of skeletons on return (frame 3 is identical to frame 1) is the shipped behaviour. On the base branch without this change, react-query's 5-minute
gcTimewould have collected the query results during the absence, and the page would re-fetch from nothing. The unit-test A/B (faked clock, 6 minutes, with and without identity) reproduces and pins the difference; these frames confirm the SPA renders in the pod with the feature code active.3. How our fix solves it
Chaining from the symptom to the root cause:
So the fix has two halves, both standing on that page-level identity.
Retention, at the page seam.
BuiltinAppRouteis the one place the host knows "this route belongs to app X" while the page's first query has not yet mounted. A sibling ahead of itsSuspenseboundary,<AppCacheRetention />, reads the appId throughuseTrustedAppId()and registersgcTimefor the[appId]key prefix. react-query matches query defaults by prefix, so that single registration covers every key the app already writes by hand.This is the first thing worth stating plainly rather than leaving for a reviewer: retention needs no change to any app. AWS Control's
['aws-control', ...]keys -- all 35 of them before this change converted two -- are already under the prefix, so the reported symptom is fixed by host code alone.useAppQuerybuys something different -- the greppable distinction between "my key" and "a key I share with the host" -- and not the fix itself. Which keeps the design's framing honest at two levels:gcTimeis an accelerator, not the mechanism, and the mechanism for landing you back where you were is the view-state record in #8407.The registration happens in a render body, never in an effect, and that is not a style preference. An effect version passes a cold-load test and still fails a user. On a cold load the page module is still being fetched, so Suspense holds the child back until after the parent has committed and its effects have run -- the ordering bug is invisible. On a repeat visit the module is already resolved, React renders parent and child in one pass, and an effect is a render too late. That is precisely the visit this design exists for. Both cases are pinned; moving the call into a
useEffectreds only the second.Namespacing, at the call site.
useAppQuery(['costs', id], { queryFn })takes the appId from context and prefixes the key, so an app cannot forge its own namespace. Code that genuinely wants the host's cache stays on plainuseQuery.The second thing worth stating up front: the prefix is exactly the appId, not
['app', appId, ...]. That is the only shape under which two requirements hold at once -- the host authors the prefix, and no existing app query key is renamed.useAppQuery(['drive', account])resolves to['aws-control', 'drive', account], byte-identical to what the app writes today, so every existinginvalidateQuerieskeeps matching. What changes is who authors the prefix, not what it is. One limit on that, stated rather than left to a reader: the hook makes the prefix host-authored for the code that calls it -- it does not stop a query elsewhere in the tree from typing another app's prefix by hand. What stops that is the ownership ratchet in section 4. The hook is how an app opts in; the ratchet is the tree-wide invariant. That matters because five prefixes are shared between an app and the host deliberately --artifact,awsConsent,apps,pull-request-source, andworkflow-definitions/workflow-runs. Renaming the last would split a deliberately shared cache in two and desynchronize the workflow cards in chat (pages/chat/ActivityViewer.tsx:843,pages/chat/WorkflowRunCard.tsx:159) from the list in the app.The third thing, and the earlier version of this section had it wrong in a way that undersold the change. I had written that retention is a no-op for
workflows,command-barandcode-review-sage, and that this set was "almost exactly" the shared-prefix table. Measured properly -- counting, per registered app, how many of its query keys sit under its own appId versus another prefix:So 12 apps and 193 keys are covered with no change to any app -- not just AWS Control -- each on the first visit to its page.
command-barandcode-review-sagewere in my no-op list and should not have been: they hold 2 and 20 own-namespace keys respectively and get retention for them; only their genuinely shared prefixes (apps,pull-request-source) are left out.The real no-op set is three apps, and only one of them for the reason I gave.
workflowsis the designed case: its keys areworkflow-definitions/workflow-runs, read by the chat cards, so the host's cache stays the host's decision.auto-improvementandauto-researchare a different thing worth naming -- their keys are['auto-improvement-config']and['research-campaign'], single hyphenated strings rather than['auto-improvement', 'config']. react-query matches defaults element-wise, so those miss[appId]by a naming shape, not by any deliberate sharing. They would be covered by writing the key as an array, which is whatuseAppQueryproduces by construction. That is a better argument for the namespacing half than the one I had been making.The silent failure this is uniquely exposed to, and how it is closed. Everything above depends on the page's identity being the one the host minted. Two builtin pages mount an api-layer provider inside their own subtree:
apps/spec-builder/SpecBuilderPage.tsx:131andapps/ops-mission-control/IncidentChat.tsx:77. Had a nested provider shadowed the host identity with its ownorigin: 'external'default, those pages' queries would fall back to un-prefixed keys while retention stayed registered on[appId]-- the app's data landing outside the very namespace being retained. There would be no error, no skeleton fix, and nothing in a diff to grep for: the feature would simply not happen on two pages, and the bug report would read "the cache fix does not work sometimes".The identity layer closes it by publishing only when there is none in context, so a nested provider cannot revoke a host-minted namespace. Two tests hold it from this side -- one per provider shape -- and the mutation result is what shows they measure the real property rather than passing by construction: deleting the
if (existing) return scopedearly return reds only theAppApiProvidercase and correctly leaves theAppScopedApiProvidercase green, because the scoped layer publishes no identity to shadow with.APP_CACHE_RETENTION_MSis 30 minutes, and deliberately issue-radar's own number rather than a second one. A test reads it back from that app's source, so the two cannot drift apart silently.One call site converted, and deliberately a mixed one.
UsagePane's costs query inConsoleView.tsxnow usesuseAppQuery(['costs', id]), while therefetchGatedinvalidation three lines below it keeps writing['aws-control', 'costs', id]by hand. They still address one cache entry, which demonstrates the byte-identity claim in the product rather than only in a test -- and it is what lets the rest of the app convert one call site at a time. The remaining hand-written sites -- 33 occurrences of the['aws-control'literal, one of them a comment -- are #8401. #8401 is what waits on #8407, not this PR: #8407 editsDrivePage.tsxandAwsControlPage.tsx, and a concurrent rewrite of those two files would conflict invisibly. Nothing in this change depends on #8407.Also worth knowing: with zero conversions,
useAppQueryhas no importer and rolldown tree-shakes it out of the production bundle entirely. The one conversion is what puts it in the shipped code (dist/assets/appQuery-*.js), so the mechanism is verifiable in a build rather than only in a test run.4. What tests we did
34 new tests across three files, plus two added to the app's own suite.
Pure rules (
test/appCacheRetention.test.ts): the retention plan is refused for a host page and an external app; the resolved key is byte-identical to the literal it replaces; a key that already carries the appId collapses instead of double-prefixing, and warns once per app;applyCacheRetentionhas no dead null-branch, proven by a restored guard reding the case rather than by a type directive; and the 30-minute value, asserted directly and against the 5-minute default it exists to beat (there is deliberately no second literal to reconcile it with any more).Mounted behaviour (
test/appQuery.test.tsx): the key lands under the app namespace; it degrades to a plain query with no identity; an external app is refused the namespace; the namespace survives either api-layer provider nested inside a builtin page (the two call sites named in section 3);useAppQueryKey()builds the same keyuseAppQueryused and an invalidation through it refetches; retention is in place before the page's first render on a cold load and on a repeat visit; one registration per client and app; nothing registered on a host page or for an external app; retention at[appId]does not reach a prefix that is not the appId; andBuiltinAppRouteactually mounts the seam.The consequence, not just the registration. All of the above proves
setQueryDefaultswas called with the right value at the right moment. It does not prove what a user feels, so two more tests do, as an A/B of one flow -- load, unmount, wait, look -- on a faked clock, in structurally identical trees differing only in whether an identity is published:Infinity)The control is what makes that evidence: granting retention to a null appId reds it, so it is measuring the real property. Six minutes of wall time costs nothing on a fake clock, which is why this is in the suite rather than deferred to a manual walk.
The invariant
[appId]retention rests on is now a test, not an audit. RegisteringgcTimefor an app's whole prefix is only safe because no query outside that app claims it -- otherwise a host query would inherit an app's retention the moment the app's route rendered. That held when this landed, established by hand, which is a fact about one afternoon.test/appPrefixOwnership.test.tsmakes it a property of the tree: it derives ownership from the registry itself (which already pairs each appId with the module that owns it, so a new app is covered the moment it is registered) and scans every non-test source file for a foreign claim. 0 violations across 22 registered apps. The detector is also fed a PLANTED violation, because a scanner that finds nothing and a scanner that looks at nothing are indistinguishable from a green test -- disabling its match reds that case and leaves the real scan green.The two ordering cases deserve a note, because the first version of them was quietly vacuous. A first-render assertion under a
React.lazychild cannot tell a render-body registration from an effect: Suspense holds the child back until the parent has committed, so the effect has already run. Only a warm second mount reds. The first version got its warmth from the cold test having run before it in the same file, which meant that run alone -- a-tfilter, a reordering, a shard split -- it passed against the effect version. It also asserted no-suspension by checking the fallback was absent from the DOM, which is true after the page arrives whether or not the tree ever suspended.Both are fixed. The cold case builds its own deferred lazy so it is cold regardless of order; the warm case does both mounts itself, with a fresh client on the second so retention has to be registered again. Suspension is recorded by the fallback component as it renders rather than read off the DOM afterwards, so each case asserts positively which path it was on: the cold one suspended once, the warm one not at all. Verified by re-running the mutation in three scopes -- with
applyCacheRetentionmoved into auseEffect, the warm case now reds alone as well as in file order, while the cold case alone stays green.Product-level (
apps/aws-control/ConsoleView.test.tsx): the host-built costs key and the hand-written invalidation beside it address a single cache entry, with only one costs entry in the cache; and the reverse, where auseAppQueryKey()-built invalidation reaches a hand-written query key and makes the pane refetch. The second went in vacuous -- it invalidated the literal rather than the builder's output, so the prefix mutation did not red it -- and was rewritten to route through the builder. The pane's 12 existing mounts now run insideAppIdentityProvider, so they exercise the namespaced path a user is on rather than the degrade path.Sixteen mutation checks, each killing a named test. Notably: replacing the render-body registration with a
useEffectfails the repeat-visit test -- run alone as well as in file order -- while the cold-load test stays green, which is the asymmetry this design warned about; changing the prefix shape to['app', appId, ...]fails both AWS Control byte-identity tests, in each direction; readinguseAppIdentity().appIdinstead ofuseTrustedAppId()fails the external-app refusal; and removing the provider's no-shadow early return fails only the nested-AppApiProvidercase, since the scoped layer publishes no identity to shadow with. Two of the newest confirm the clock A/B is not vacuous: setting the retention to react-query's own 5 minutes reds the retained case, and granting retention to a null appId reds the CONTROL.tsc -bandeslintclean. 171 tests green across the affected files, including the identity seam's own suites, the provider-split suite, and the chat protocol boundary pin.Pod evidence: the pod is running this code, not a stale bundle
Verified in an isolated pod on a pinned port, never the running gateway. Recorded verbatim, because "the pod served an older bundle" is the specific failure a screenshot would otherwise be guarding against, and a grep of the served asset answers it directly.
Positive -- strings from this diff, fetched from the pod over HTTP:
Negative -- what this change removes, absent from the served bundle. Two cases, both measured:
The first pair shows the costs query moved to the host-built key while its invalidation did not, in shipped output. The second pair is issue-radar's app-local retention being genuinely gone rather than commented out: no registration, and not even the 30-minute literal, which now exists in exactly one place in the shipped bundle.
One detail worth reading, because it looks like the build did not run: deleting
CACHE_RETENTION_MSfrom issue-radar left every chunk hash unchanged. That is the correct outcome and is itself evidence -- the constant had no production reader left, so it was already being tree-shaken out, and removing it changed no emitted bytes.Note what is grepped and what is not: every pattern above is a string literal, an object key, or a chunk filename, because those are what survive minification. A grep for a function name is unreliable in both directions, measured across this same dist:
Zero would not have meant absent, and the one non-zero result is not the function either. Hence literals.
The pod's gateway also boots healthy on this change, and
website/distand the servedsrc/kiro_crew/static/distcarry the sameappQuery-BTmvLjqw.js, so the served copy is this build and not a leftover.Outstanding: full-window screenshots, and the real-clock walk
Two items are outstanding on the record rather than dropped, and neither is a waiver.
Full-window screenshots are not attached yet. The agent cannot mint a dashboard credential for the pod:
kirocrew pod tokenis blocked precisely because it prints a credential, and the pod's own credential is withheld here because the guard cannot prove which process holds the port withoutlsof/netstaton this host. Reading the pod's secret file to mint a token is the same door with a different handle, so it was not done. No cropped or component-level image is offered as a substitute either.The end-to-end walk on a real clock is also outstanding: open AWS Control, descend a folder, leave, wait past five minutes, return, and see it repaint with no placeholder. That needs a logged-in dashboard, which is the same missing credential, so it belongs in the same batch as the captures. The mechanism itself is covered without it -- the faked-clock A/B above reproduces the symptom and its absence, and the byte-identity test pins the key -- but the walk is what confirms it in a browser rather than in jsdom, and it is not being claimed as done.
Both get attached once a credential is pasted in or the frames are taken from a logged-in dashboard.
5. Any other suggestions on the work
Two advisory review findings were acted on rather than argued with, and one was declined with reasons.
Acted on -- issue-radar's duplicate registration is deleted, and so is its constant. The registration was worse than redundant, which the first version of this PR body got wrong.
setQueryDefaultsis a Map keyed by the hashed key, so the host's['issue-radar']registration and the app's own wrote the SAME entry and the last writer won -- and which one was last depended on module evaluation order (the host registers while rendering the route; the app's module-scope line runs when its lazy chunk evaluates, after). Identical values hid it; any future divergence would have been decided silently by chunk timing.Removing the registration then left
CACHE_RETENTION_MSwith no production reader at all -- a constant existing only to be reconciled against another constant by a test. So it is deleted too, its rationale is folded intoAPP_CACHE_RETENTION_MSwhere the number now actually lives,issueRadarPolling.test.tsxreads the platform constant, and the source-reading drift test is gone with the drift it was watching for. One constant beats two literals kept in step by a test.Acted on --
useAppQueryKeyno longer ships without a consumer.refetchGatedinConsoleView.tsxnow builds itsdriveinvalidation through it. That also strengthens the demonstration instead of diluting it, because the pane is now mixed in BOTH directions:costsis queried by the host and invalidated by a hand-written literal,driveis queried by a hand-written literal and invalidated by the host. Either mismatch would break a grant's ability to refresh what it changed. Changing the prefix shape to['app', appId, ...]reds both cases.Acted on --
useAppCacheRetentionreturnsvoid. It was handing back the plan that nothing read: its one caller renders nothing from it, andresolveCacheRetentionalready exposes the same value purely, so the return was a second way to obtain something no caller wanted.Acted on, ahead of the suggestion -- the safety claim is a test now. Registering
gcTimefor an app's whole prefix is only safe because no query outside that app claims it; that was established by hand during this work, which is a fact about one afternoon rather than a property of the tree. Since this PR body asserts the claim, it should be the PR that pins it:test/appPrefixOwnership.test.tsderives ownership from the registry (already the appId-to-module map, so a new app is covered on registration) and scans every non-test source file for a foreign claim -- 0 violations across 22 apps -- with the detector additionally run against a planted violation so a green scan is not the same as a scan that looked at nothing. Suggested as a follow-up to #8401; done here because the assertion is made here.Answered -- whether the namespacing hooks belong in this PR at all. Retention alone fixes the reported symptom, so
useAppQueryanduseAppQueryKeycould in principle be split out. They are here because the approved delivery plan puts them here: its PR-3 row reads "Cache retention:useAppQueryauto-prefixing plus per-appgcTime; AWS Control as first consumer". The two halves also answer one question together rather than separately -- retention needs a namespace to attach to, and without a host-authored way to say "this key is mine" the next app has no way to opt in except by naming its keys after itself and hoping. Each hook has exactly one production consumer, both in this PR, both exercised in opposite directions by the same pane.Declined -- registering all app prefixes in a module-scope loop over the registry. The suggestion is to drop the render-time seam and instead call
setQueryDefaults([appId], ...)for every registry entry at startup, deleting the component, the per-client memo, the render-body ordering constraint, and the cold/warm test apparatus. It is a real simplification and the reasoning is sound; two things argue against it, and one argument for keeping the current shape turned out NOT to hold:pages/DevFleetPage.tsx, is the dev-fleet app's own page, filed outsideapps/.getQueryDefaultsiterates every registered default on every query's option resolution, which is every query on every render across the whole dashboard. That is 23 prefix comparisons instead of one or two, permanently, for prefixes belonging to apps the user never opened. Small, but not the "semantics are identical" the suggestion assumes.Left as it is, deliberately, and recorded here so the option is on file rather than lost.
Acted on --
useAppCacheRetentionis module-private. It was exported with no consumer outside its own file; the only caller is theAppCacheRetentioncomponent beside it.app-sdk/appQuery.tsnow exports exactly three things: the component,useAppQueryKey, anduseAppQuery.Held -- folding
resolveCacheRetention/applyCacheRetention/CacheRetentionPlaninto the hook. The consumer counts are accurate, and inlining would collapse them to about three lines. Two reasons it stays split, the second being the substantive one. The approved design names it directly -- "KeepresolveCacheRetentionand identity resolution as pure functions, following the shape ofapps/overlaySlots.ts" -- with the stated reason that a rule deciding where a user's cached data lands should be verifiable without mounting a tree. AndresolveAppQueryKeyhas to stay in that module either way (the review agrees: two consumers, real logic), so folding the other two removes no file and no import -- it moves the retention rule out of the pure module that survives regardless and into the React one, which is a relocation rather than a subtraction. If the maintainer wants the smaller surface anyway, it is a contained change and I will make it.Held -- the already-prefixed warning in
resolveAppQueryKey. Correct that no current call site can trip it, because there is one converted site and it is hand-verified. That is precisely what it is for: the 34 conversions in #8401 are where a careless one silently forks a query off the namespace its own invalidations match, and the symptom then appears as stale data after a write, far from the line that caused it. A warning is cheap now and expensive to add after the sweep.Answered, and the first version of this was wrong -- why not just raise the global default. Setting
gcTimebeside the existingstaleTime: Infinityinapi/queryClient.tsis one line and would remove the symptom for every app at once, including the three this change leaves as no-ops. This PR body previously rejected that by asserting "chat and sessions hold large result sets on the same client", and First Principles is right that nothing here measured it. So that claim is withdrawn: the memory differential of holding host keys 25 extra minutes is not measured, and this PR does not claim it is.What the choice actually rests on does not need a measurement.
api/queryClient.tsdefaults reach chat, sessions, and every host surface -- code this change does not touch, does not test, and whose owners did not ask for a retention change. A per-app registration changes only the app whose page is open, is one legible line per app, and is revertible per app; a global default is one line to write and a dashboard-wide behaviour change to reason about. That asymmetry, not a memory number, is why the approved design put the global raise out of scope.It is worth saying plainly that this makes the global raise the cheaper change, not a worse one. If it is what the platform wants, it should be measured and taken on its own -- with chat and sessions in the blast radius considered by the people who own them -- rather than arriving as a side effect of an app-scoped cache fix.
Held, with the reason stated a third time -- deferring the namespacing hooks to #8401. Raised in each review round and answered the same way: the approved delivery plan's PR-3 row specifies both halves together, and retention needs a namespace to attach to or the next app can only opt in by naming its keys after itself and hoping. First Principles marks it concerns-not-block on the same grounds. If the maintainer prefers the split, it is a mechanical extraction of two hooks and one call site and I will do it -- but it is a plan change, not a defect.
Held, though my first reason for holding was wrong -- the injectable client on
AppCacheRetention. It has no production consumer;BuiltinAppRoutemounts it bare. I first defended it on test isolation, and the review correctly answered that the retention tests already mount their ownQueryClientProvider, souseQueryClient()would return the per-test client and isolation would be unaffected. That was right and my reason was not.The actual blocker is measured rather than argued.
AppCacheRetentionis mounted byBuiltinAppRoute, and that route's own suite renders it with noQueryClientProviderat all, so switching touseQueryClient()turns those renders into "No QueryClient set": 3 tests in the stacked change below this one, plus 1 of mine. Verified by making the switch and running both suites. Fixing it means editing a test file that belongs to another change in the stack while that change is review-ready, which is not this PR's to touch. Once it lands, the switch is a two-line change and I will make it.useAppInfiniteQuery. Infinite queries,setQueryDataandinvalidateQueriesall take a key fromuseAppQueryKey()and stay on the plain react-query hook, so one resolver serves every cache API. Adding a wrapper later is additive; two of AWS Control's sites are infinite queries and will use the key builder.QueryClienttoday, becauseapp-sdk/shared-modules.ts:40registers the live react-query instance onwindow.__kirocrew_moduleswhile the scoped-API layer scopes only REST paths. That is whyuseAppQueryis off theapp-sdkbarrel and imported by path -- but the barrel is not what is leaking.