Skip to content

backmerge: main into develop (1.21.0) - #1099

Merged
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.21.0
Sep 14, 2026
Merged

philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.21.0

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Reconciles develop with main after the 1.21.0 squash-merge (#1097, 51fb1d5f).

⚠️ Merge this with a MERGE COMMIT, not a squash

The entire point is to make main a genuine ancestor of develop. Squashing recreates the divergence and the conflicts come back at the next release. (Feature branches into develop squash as usual — the backmerge is the deliberate exception.)

What's in it

Only the release artifacts the release commit wrote — develop had not touched any of them since the last reconciliation, so this auto-merged with zero conflicts:

File
VERSION 1.20.0 → 1.21.0
CHANGELOG.md / RELEASE_NOTES.md the 1.21.0 entries
README.md badge + current-release line
backend/pyproject.toml, tui/pyproject.toml, tui/src/agentcore_tui/__init__.py version
frontend/ai.client/package.json, infrastructure/package.json version
backend/uv.lock, tui/uv.lock, both package-lock.json regenerated

13 files, 520 insertions, 16 deletions — identical to the release commit's own content. No feature code is touched in either direction.

Verification

  • sync-version.sh --check[PASS], all manifests at 1.21.0
  • git merge-base --is-ancestor origin/main HEADpasses
  • Both docs lead with the 1.21.0 entry above untouched previous entries
  • Spot-checked that 1.21.0 feature code survived the merge (config_cache.py, customize-tool-detail.page.ts, backfill_tool_catalog_index.py all present)

🤖 Generated with Claude Code

philmerrell and others added 2 commits September 13, 2026 21:40
* feat(artifacts): share a conversation's artifacts with the conversation

Closes the gap §8 of docs/specs/artifact-sharing.md has carried since
the feature shipped: a recipient of a shared conversation saw nothing
where the owner sees artifact cards. Silently — no placeholder, no
error — because artifact hydration filters HEAD rows by the requesting
user. It stayed open because the fix needed a consent decision rather
than wiring. The decision is that sharing a conversation shares the
artifacts it produced.

## The conversation share is the grant

No artifact share records are created for this, which is where the
original sketch in §8 pointed and where this deliberately does not go.

`create_share` pins the session's artifacts — at the version each stood
at right then — into the snapshot body next to the messages. That does
two jobs. It keeps the point-in-time promise the snapshot already makes,
so a recipient reading a frozen conversation is not shown an artifact
the transcript around it never describes. And it makes the snapshot the
ALLOWLIST.

`resolve_shared_artifact` is then the entire access boundary, and does
both halves: may this viewer open this share, and is this artifact one
the snapshot pinned. It hands an owner id and a pinned version to
`mint_for_conversation_share`, which checks nothing itself and says so
in a comment — the token's `sub` is a DynamoDB partition address, so
without the second half any valid share id plus a guessed artifact id
would read the owner's whole artifact partition.

Provisioning parallel artifact shares was rejected on two grounds. Each
would need cascading on update, revoke, artifact delete and session
delete, and a missed cascade leaves an artifact readable after its
conversation was locked down — a security bug, not a display one. It
would also put N rows in the recipient's "Shared with you" inbox for one
conversation share, when the conversation is the thing that was shared.

The payoff is that access has one source of truth: narrow a
conversation's allowlist and its artifacts lock down in the same write;
revoke it and they go with it. Both are tested as such.

## Compatibility

Conversation sharing is in production, so the snapshot's `artifacts` key
is optional on read: bodies written before this exist, and legacy inline
shares predate the S3 offload by a wider margin still. Both read as an
empty list. No migration, no schema bump — the addition is additive.

Capture is best-effort. Sharing a conversation must not fail because the
artifacts feature is off in an environment or its table hiccuped; a
share with no artifacts is what every share was until now.

## Also

`_load_snapshot_body` is now a narrowing of a new `_load_snapshot_raw`,
so a caller wanting another key of the body does not have to widen the
tuple every existing caller destructures.

`heads_for_session` returns one row per artifact at HEAD, off
SessionIndex alone — the shape a snapshot wants, where
`list_for_session` returns one row per version for the session view's
per-turn anchoring.

Backend: 914 passed across app_api + architecture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): admin list and form pages

PR-3 of docs/specs/feature-announcements.md. Removes the "author by curl"
step: announcements can now be written, scheduled, published, archived and
re-shown from the admin console.

- `manage-announcements.page.ts` — list with state chips, surface icons, and
  the lifecycle actions an announcement has and a link does not (Publish,
  Show again, Archive). Mirrors `manage-user-menu-links`.
- `announcement-form.page.ts` — title, markdown body with live preview,
  surface checkboxes, severity, schedule, role picker, `showToNewUsers`,
  `requiresAck`, CTA.
- Nav entry under Customization, `data: { scope: 'admin.announcements' }`.

Three server rules are mirrored in the form so an admin finds out before
submitting rather than through a 422: `expiresAt` is required once a banner
or modal is selected, `ctaLabel`/`ctaUrl` travel together and must be
http(s), and the body cap is counted in **bytes** (the server's limit is
16 KB of UTF-8, so a 3-byte character costs three).

The panel checkbox is deliberately rendered checked-and-disabled rather than
omitted. The server forces `panel` on regardless (§D1); showing it explains
why dismissing a banner never destroys the information, where silently adding
it after save would not.

Two lifecycle guards are carried into the UI rather than left to the API:
- Edit never sends `state`, and "Show again" is a separate action from
  editing — a typo fix must not re-fire a modal at everyone (§D4). The
  confirm text says exactly that.
- Publish is not offered on an archived announcement, because the server
  refuses it; offering a button that returns a 400 is worse than no button.

The markdown preview uses `message-block`, the app's real markdown
stylesheet, so what the admin previews is what the panel renders. The `prose`
classes would be inert — the Tailwind typography plugin is not installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(artifacts): render a shared conversation's artifacts for its recipient

PR-2 of the pair. #971 made the artifacts reachable; this makes them
visible. A recipient now sees artifact cards anchored under the same
turns the owner sees them under, and opens them read-only.

## A separate recipient component, not a mode of the owner's

`ArtifactCardComponent` opens the docked panel and carries download,
share, rename and delete; `ArtifactPanelComponent` adds a version picker
and a code view. Every one of those is keyed on something a
conversation-share recipient does not have — an owned artifact, or an
artifact share id. Bending either into a "read-only mode" would mean a
component whose every action is conditional on a flag, and the failure
mode of a missed condition is a visible button that 403s.

So the card and dialog are their own thing. What IS shared is the layer
that should be: `ArtifactViewerComponent`, which is purely presentational
and already served the owner panel and the standalone recipient page
through two mint endpoints. This is the third, and it needed no change
to that component — which is the sign the split was drawn in the right
place.

## Artifacts arrive as an input, not through the state service

`MessageListComponent` reads artifacts from `ArtifactStateService`, which
is the OWNER's live session state: populated by SSE events and
owner-scoped hydration, neither of which a recipient has. Feeding it
recipient rows would put another user's artifacts into the signal the
real session view reads. So the shared view passes them down, and the
list groups them with the same index-anchoring logic — including the
orphan fallback, so an artifact with no usable anchor lands in the end
strip rather than disappearing, which is the exact failure this whole
feature exists to fix.

## No code view

The source endpoint is keyed on an artifact share id, which a
conversation share does not have. The toggle is therefore absent rather
than present and permanently failing. Adding it is a backend change, not
a UI one.

A refused artifact and a missing one get one message: "not part of this
share" and "you may not open this share" are the same fact to a
recipient, and telling them apart would describe what the owner has.

SPA suite: 2367 passed (+26). 20 new tests: card, dialog (including the
sandbox isolation the third mint path must not weaken, and the
superseded-mint race), and the message-list anchoring — which had no
component spec at all before, so the orphan-fallback branch the owner
path also relies on is now covered.

Verified in the browser against the compiled stylesheet at 1080px in
both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(announcements): submit button could never enable on the new-announcement form

Found by browser-verifying the page in dev: every field filled, the form
reporting `ng-valid`, and "Create draft" still disabled. No announcement
could be authored from the UI at all.

`canSubmit` is a `computed`, and a computed tracks the signals read during
its *last* execution — so an early `return` shortens its dependency set. The
guard chain read `isSubmitting()` and then `if (this.form.invalid) return
false`, and `FormGroup.invalid` is a plain getter, not a signal. On the first
evaluation the form was empty, so it returned there having tracked only
`isSubmitting`. No later edit could schedule a recompute, and `isSubmitting`
changes only inside `onSubmit` — which the disabled button prevented.

Two changes, both load-bearing:
- form validity is mirrored into a signal fed by `statusChanges`, like the
  other `valueChanges` mirrors already in this component;
- every input is read unconditionally before being combined, so no branch can
  shrink the tracked dependency set again.

The three new tests read `canSubmit()` while the form is still **empty**,
then fill it. That ordering is the whole point: the existing 24 specs only
ever read it after filling, so the computed's first evaluation saw a valid
form, tracked everything, and stayed reactive — all 24 pass against the
broken code. Verified by reverting the fix: the 3 new tests fail, the other
24 do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(artifacts): stop blaming a search the user never made

Opening "Shared with you" with nothing shared showed "No artifacts match
your search" — with an empty search box. Found on dev the moment the
inbox flag went live.

`isFilteredEmpty` gated on the LIBRARY total, so any non-empty library
made an empty tab look like a failed search. It needed the SELECTED
TAB's count instead. The irony is that the comment above it already
warned about exactly this conflation ("'Nothing matches' is a different
message from 'you have nothing'") — tabs added a third state, "nothing
*here*", and the old gate quietly folded it into the wrong one.

So there are now three, in priority order:

  isEmpty         nothing anywhere        "No artifacts yet" + CTA
  isTabEmpty      nothing in this tab     names the tab
  isFilteredEmpty filtered to nothing     "No artifacts match your search"

`isEmpty` still wins when the library is empty outright: a per-tab
message would bury the one statement that actually matters.

Why the tests missed it: every empty-state spec asserted which ROWS
rendered, never which SENTENCE appeared when none did. The three added
here assert the sentence, including the case where blaming the search
is correct.

SPA suite: 2431 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): render the banner surface (PR-4)

The `banner` surface has been authorable since PR-1 and computed by the
server since PR-2, but nothing in the SPA consumed `bannerItem()` — an
admin who ticked "banner" got a field that did nothing, with no way to
tell from the UI that the surface was unbuilt.

`components/announcement-banner` renders the one banner the server picked
(§D7) as a strip at the top of the shell: severity icon and colour from
the `state-*` scale, the `summary` line when the author wrote one, an
optional CTA, and a ✕ that records a durable `dismissed` ack. It writes
`seen` on render — once per announcement per tab — which is what clears
the unread dot for someone who reads the banner and never opens What's
New. That write races the ✕, and deliberately relies on §D2's monotonic
server-side rank rather than ordering the two client-side.

Placement is a flex child of the shell's `<main>`, above the scroll
container, so content reflows instead of hiding underneath. Three pieces
of viewport-fixed chrome would otherwise paint over it, so the strip
publishes its measured height as `--announcement-banner-height` and they
offset against it: the chat topnav, the full-page empty-state overlay
(which was `inset: 0`), and the two floating sidenav control clusters.
The height is measured rather than hardcoded because the line wraps on
narrow viewports. The voice overlay still covers it, which is right —
that one is a modal.

Gated on `isAuthenticated()`, not just chrome. `AnnouncementsService`
loads its feed on the first read of `bannerItem()` and `resource()` loads
exactly once, so mounting the banner on the login screen would fire
`GET /announcements` unauthenticated, take the 401's empty-feed fallback,
and never retry — announcements would be missing for the life of the tab.
Found in the browser, not by a spec.

Verified end to end against dev data with a local app-api: strip renders
in light and dark and at 375px with no horizontal overflow, ✕ writes an
ack that upgrades the existing `seen` row in place to rank 2 rather than
duplicating it, the server then returns `banner: null` while the panel
entry survives (§D1/§D2), and deleting the ack brings the banner back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): render the modal surface with the §D8 gate (PR-5)

The interruptive surface, and the last one. `components/announcement-modal`
renders the single modal the server picked; `AnnouncementModalService` decides
whether interrupting is acceptable at all.

The gate is the substance of this PR. The modal opens on route settle and only
when there is no active stream, no pending tool-approval / OAuth-consent /
MCP-App-consent prompt, no draft in a focused composer, and the route is not a
minimal-chrome page. The consent checks are not redundant with the stream
check: per mid-turn-steering (#934) `isLoading()` is FALSE while a turn is
paused on an interrupt, so a stream-only gate would throw a dialog over an
OAuth consent prompt and steal its focus. The prompt services are asked
directly.

Every gate input is read `untracked`. Read reactively, the effect would re-run
the instant a stream ended or a consent was answered and fire a modal seconds
after the user finished a thought — which §D8 forbids in as many words: a
failed gate leaves the announcement eligible for the next clean load, it does
not queue it. So the effect tracks only the announcement and a navigation
counter, and snapshots the rest.

`requiresAck` makes the confirm button the only exit: no ✕, `disableClose` on
the overlay, and the in-component Escape and backdrop handlers return without
writing an ack. Belt and braces on purpose — the CDK option and the guards
fail independently. The button label follows the ack it writes, "I understand"
→ `acknowledged` and "Got it" → `dismissed`, so it cannot misdescribe the
record.

Started via `provideAppInitializer` rather than mounted in app.html: a CDK
overlay is not a layout element, and nothing else would ever inject the
service. Same shape as ThemeService. It also means this PR does not touch the
app shell, so it does not conflict with PR-4.

Body uses `.message-block`, not `prose` — the typography plugin is not
installed, so the older user-menu-link-modal's classes are inert and strip
list markers. Sanitization stays on (§D10): `admin.announcements` is
delegable, so this body may be authored by a non-admin and reaches every user.

Verified end to end against dev data with a local app-api. With `requiresAck`:
opens on load, no ✕, Escape and backdrop clicks leave it open, and the button
writes `acknowledged` at rank 3 — upgrading the `seen` row in place rather
than duplicating it. Without it: ✕ and Escape both write `dismissed` at rank
2. Afterwards the server returns `modal: null` while both panel entries
survive (§D1/§D2). Light, dark, and 375px with no horizontal overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): ack funnel counters + GET /{id}/stats (PR-6 backend)

`/stats` needs a count of acks across users, which the key shape does not
support: acks live under `USER#<id>` partitions, so counting them per
announcement means a GSI on `announcementId` or a scan. The spec ranks those
second and third and says start with atomic counters on the announcement item
(§9). This does.

The counters are top-level attributes — `ackCountsR1Seen` and friends — not a
nested `ackCounts` map, because DynamoDB's `ADD` only works on top-level
attributes and creates a missing one as 0 in the same atomic write. A nested
map needs `SET path = if_not_exists(path, :zero) + :one`, which raises
ValidationException until the parent exists, so every announcement authored
before this shipped would need an init-then-retry branch on the ack hot path.

They count users, not clicks. `record_ack` now reads the previous rank via
`ReturnValues="UPDATED_OLD"` and bumps only the ranks the write crossed, so
`seen` then `dismissed` adds one to each rather than two to `seen`. They are
a funnel, not a partition: acknowledged implies dismissed implies seen, so
`seen >= dismissed >= acknowledged` holds without ever reading them back.

Keyed by revision, because "Show again" (§D4) is a deliberate re-broadcast and
rolling its acks into the previous revision's totals would inflate them and
make the numbers lie about the version people actually saw.

**The bug worth reading twice:** every admin mutation — `update_announcement`,
`set_state`, `bump_revision` — is a full `put_item` of the `Announcement`
dataclass, so any attribute the model does not carry is destroyed by it.
Publishing an announcement, the most common admin action there is, silently
zeroed every counter. `Announcement.ack_counts` now carries them through
read → write. Four regression tests cover publish, archive, edit, and
continued accrual afterwards.

`targeted` is answerable only for a `"*"` audience, via a COUNT query on the
users table's StatusLoginIndex. That index is projected INCLUDE without
`roles`, so a role-filtered count has nothing to evaluate against, and the
alternatives are worse than an honest null: replacing a GSI on the users table
(CFN reports green well before an index is ACTIVE), or the scan the spec ranks
last. Nor is there a membership list to count — roles arrive as JWT claims
mapped at login. Null means "not estimated", never zero.

Increments are best-effort by design: a second write after the ack is already
durable, logged and swallowed on failure. An under-counted stat beats turning
a successful acknowledgement into a 500.

18 new tests; full backend suite 2329 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): show reach on the admin list (PR-6 frontend)

Completes PR-6. The admin list now carries a reach line per announcement —
"2 seen · 0 dismissed — of ~68 targeted (estimate)" — which is the point of
the whole surface: it tells you whether any of this works.

Rendered as a funnel, not a partition. "12 seen · 8 dismissed" means 8 of
those 12, because the stored rank only ever rises through them (§D2).
`acknowledged` appears only where one was actually asked for; on an
announcement without `requiresAck` the number is real but meaningless, and
showing a third figure that is always equal to the second reads as a bug.

Two cases render nothing rather than a zero:

- **A draft.** Nothing has been shown, so "0 seen" would read as "nobody
  engaged" instead of "not sent yet". `hasReach` gates on published/archived,
  which also keeps the fetch off every row an admin is still writing.
- **A role-scoped audience.** `targeted` is null there — the users table's
  StatusLoginIndex does not project `roles` — and "of ~0" would imply nobody
  is targeted. It says "audience not estimated" instead.

Stats are a second endpoint per announcement, so they load after the list
rather than blocking it, and only for rows that have been live. The cache is
keyed by **id plus revision**: "Show again" restarts the counters, so an entry
from the previous revision would report stale reach for a broadcast that has
only just gone out. A failed fetch is dropped from the requested set so the
next pass retries, and leaves the row without a reach line rather than
blanking the list — the page's actual job is CRUD.

The hover text and the "(estimate)" suffix carry the §11 caveat. One more is
now documented on the response model: **nothing is backfilled.** The counters
are incremented by the ack write path, so acks recorded before this ships are
invisible — an existing environment starts every announcement at zero on
deploy day even where people have already read and dismissed it. Verified
against dev, where four ack rows predate the counters and only the two written
since are tallied.

7 new service specs, 8 new page specs; full frontend suite 2486 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(announcements): float the banner instead of occupying layout

Dismissing the banner pulled the whole view up by its height. It was a flex
child of the shell's `<main>`, so appearing and disappearing reflowed
everything below it — the jump was the bug, and reserving the space forever
would have been a worse fix.

It is now positioned `absolute` against a `relative` `<main>`: a rounded,
shadowed pill floating over the content rather than a full-bleed strip
displacing it. Measured before and after a dismissal, every content element —
scroll container, greeting, composer — moves by exactly 0px in both axes.

The overlay removes the reason anything had to know the banner's size, so this
deletes more than it adds:

- `--announcement-banner-height`, its `ResizeObserver`, the height signal, and
  the `DOCUMENT`/`ElementRef`/`DestroyRef` injections all go
- `.chat-topnav-wrapper` goes back to `top: 0`
- `.chat-container-empty.full-page` goes back to `inset: 0`
- both floating sidenav control clusters go back to `top-4`

`top-16` is the one constant that replaces all of it, and it is not arbitrary.
On a chat route it lands the pill immediately below the fixed topnav — the
placement §D1 asks for — and everywhere else it clears the shell's floating
sidebar buttons, which sit at `top-4` and would otherwise be overlapped by a
centred pill on any viewport narrow enough for the two to meet. Verified at
375px: the controls end at y=56 and the pill starts at y=64.

The positioning strip spans the full content width, so it is
`pointer-events-none` with `pointer-events-auto` on the pill alone — otherwise
an invisible band would swallow clicks aimed at the topnav and the sidebar
buttons beneath it. Verified: a click 30px outside the pill lands on the chat
container, not the banner.

`relative` on `<main>` is load-bearing. Without it the pill anchors to the
viewport and drifts out from under the sidenav's padding transition.

Browser-verified against dev data in light and dark and at 375px, with no
horizontal overflow. Full frontend suite 2474 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(costs): derive GPT-5.6 rates from a single-model day, not a 1000x-wrong blend

`--rates-only` could never have produced a usable number. Three defects, all
found by actually running it against dev-ai:

1. It filtered usage types on the substring `gpt-5.6`. No usage type contains a
   model id, so the filter matched nothing and the script reported "Cost
   Explorer lags ~24h" — a lag message for a search that was never going to
   match, which is the worst possible failure mode for a tool whose whole job
   is to answer "have the numbers landed yet?".
2. It multiplied every rate by 1000 to convert from 1K-token units. These
   models bill through AWS Marketplace in units of **1M tokens**, and Cost
   Explorer declares the unit in its own `Unit` field. Every derived rate was
   overstated 1000x. It now reads the declared unit and converts accordingly.
3. It read MONTHLY. Daily rows come back as exact round numbers; a multi-day
   window silently blends models into an average that looks like a rate.

The blend is not hypothetical, and it is why this needed a guard rather than a
fix. Marketplace usage types carry the token bucket and the service tier but
never the model, so every OpenAI-family model in the account shares the same
four rows — verified against USAGE_TYPE grouped by OPERATION and by
BILLING_ENTITY; no finer dimension exists. August shows two distinct price
cards ($5.50/$27.50 and $2.20/$11.00) and 2026-08-31 is visibly a blend of the
two. A rate is therefore only a given model's rate on a day when it was the
sole OpenAI-family model to run, so `--table` now reconciles against what we
recorded in sessions-metadata and refuses to vouch for a number otherwise.

I nearly shipped the mistake this guard prevents: a first read of Aug 20-31 gave
a cache-read rate matching gpt-5.4's 0.1x to four decimals, and a reconcile then
showed zero GPT calls in that window. The match was coincidence.

This also closes off the spec's Option 1. The Price List API has no Marketplace
service code at all (all 269 enumerated), and the Marketplace Catalog API is
seller-side. These rates are not unpublished-yet; they are unpublishable through
any pricing API while they bill this way, so waiting will not produce them.

Bearing on the tier/long-context modelling gap PR-3 must resolve: every row ever
seen in this account is `_standard` and no `-long-ctx` usage type has appeared,
so a flat standard rate is correct for current traffic and a change would show
up as a new usage type. That makes the gap monitorable rather than blocking.

Controlled window claimed 2026-09-06 for `us.openai.gpt-5.6-sol` (dev had zero
recorded calls beforehand); expected token totals are recorded in the spec so
the read is a verification rather than a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(costs): correct every GPT-5.6 rate — they were published in the model cards

I concluded yesterday that these rates existed in no source and had to be
derived empirically. That was wrong, and the error was one of scope: the search
ran against pricing *APIs* — Price List, then Marketplace Catalog — and stopped
there. AWS publishes them in prose on each model's card in the Bedrock User
Guide, alongside caching support, context windows, service tiers and endpoint
support. Absence from an API is not absence from the docs.

Every dev GPT-5.6 row was wrong, and every error over-charged by exactly 20%
(corrected in the dev catalog 2026-09-06T15:59Z):

  sol    output                     26.40  ->  22.00
  terra  in/out/cache read/write    2.64 / 15.84 / 0.264 / 3.30
                                 ->  2.20 / 13.20 / 0.22  / 2.75
  luna   in/out/cache read/write    0.264 / 1.584 / 0.0264 / 0.33
                                 ->  0.22  / 1.32  / 0.022  / 0.275

The 1.2x is not coincidence: Terra and Luna were sourced wholesale from the
GovCloud Price List rows, which are exactly 1.2x commercial. Sol's output was
the one figure with no source at all — a 6x input ratio inferred from GovCloud,
where the real ratio is 5x. `openai.gpt-5.4` was already correct, empty
cache-write cell included, so yesterday's prod fix is confirmed by the card.

This also resolves the tier/long-context gap PR-3 was blocked on, rather than
merely downgrading it as the previous commit claimed:

- Service tiers do not apply. Every card says Priority and Flex are not
  supported for these models, so the 0.5x/2x dimension does not exist here.
- Long context is real, and the spec's "2x twin" was wrong: above the 272K
  threshold input is 2x but output is only 1.5x. A flat 2x would have
  over-priced long-context output by a third.
- We do not reach it. All rows carry maxInputTokens 272000, pinned at the
  short-context boundary, and compaction runs at 100K — so one short-context
  rate is correct, and that cap is what keeps it correct.

Noted for the prod rows: `global.openai.gpt-5.6-*` prices 9.1% below the `us.*`
Geo CRIS card across every bucket, and prod already runs Claude on `global.*`.
Prod should not be a copy of the dev rows.

The empirical work in the previous commit is not wasted — it is now the audit
of these published numbers instead of the source of them, and the 2026-09-06
Sol window should reproduce 4.40 / 0.44 / 5.50 / 22.00 rather than discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(models): curate GPT-5.6 Sol, Terra and Luna (PR-3)

Adds `CURATED_BEDROCK_RESPONSES_MODELS` behind a new "Bedrock Responses"
catalog tab, so the three GPT-5.6 models are one-click-creatable instead of
requiring the escape-hatch form. Rates are the published Geo CRIS
short-context row from each AWS model card — Geo CRIS is the tier the `us.*`
inference profiles resolve to, and these models are inference-profile-only.

Two values are pinned by test because they are pricing correctness, not
preference:

- `supportsCaching: true`. These models cache implicitly server-side with no
  way to turn it off, so `false` is not a preference but a false statement,
  and its only effect is to clear the cache-rate fields — pricing cached
  tokens at $0.00 while AWS bills them in full. On a warm conversation nearly
  every input token is a cached one.
- `maxInputTokens: 272_000`. These have a 1M window but AWS prices them on two
  cards: above 272K, input costs 2x and output 1.5x. A CuratedModel holds one
  flat rate per bucket, so this cap is what keeps that single rate honest.
  Raising it silently opens the second price card.

Fixes the curated `openai.gpt-5.4` Mantle entry in the same pass. It inherited
`mantleDefaults()`' `supportsCaching: false`, so one-click-creating it produced
exactly the mis-priced row that had to be repaired by hand in prod last night.
Its card publishes a cache-read rate at 0.1x input and an em dash for cache
write, so caching is on with a literal 0 write rate — 0 is the correct value
rather than a missing one, because it makes `compute_wasted_usd` see a
non-positive premium and return $0 instead of inventing waste. The
`mantleDefaults()` comment claiming Bedrock caching is model-bound to
Claude+Nova was simply wrong and is corrected.

`claudeRates` becomes `ratesWithDerivedCache`: the 1.25x write / 0.1x read
multipliers are not Claude-specific. The GPT-5.6 cards publish the same two,
and commercial Cost Explorer billing reproduces them to four decimals — two
model families, two independent sources, same ratios.

`supportedParams` is deliberately absent from the new entries. AWS publishes no
parameter table for GPT-5.6 (`model-parameters-openai.html` covers only the
open-weight gpt-oss family), and a declared spec flips the #915 guard from
permissive to restrictive — so an invented one would silently block parameters
the model actually accepts. Better none than a guess.

Not browser-verified: the page is admin-gated against the dev backend, so an
unmerged frontend change cannot be signed in to. Layout risk is low — the tab
strip is `flex-wrap` and the card grid is unchanged — but the visual check is
worth doing on dev after merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): move the banner above the chat composer

The banner sat at the top of the shell. What it announces — a new model, a
new capability — is acted on in the composer, so the notice now lives where
the decision is made rather than in a corner the eye has already left. It
mounts from `chat-input` beside `quota-warning-banner`, which is where users
already look for ambient notices.

It still floats rather than stacking in flow. `bottom-full` against a
`relative` chat-input host puts it clear of the quota tabs, which stay
attached to the input, and keeps the property from the previous change:
measured before and after a dismissal, the composer, greeting and scroll
container all move by exactly 0px. Restyled to match the sibling it now sits
beside — a compact shrink-to-fit pill rather than a bar spanning the composer,
which also shrinks how much it overlays.

**It is now a chat-view surface only.** That is the real consequence of the
move and it is deliberate: What's New remains the everywhere-record, which is
why `panel` is forced onto every announcement server-side. The spec's §D1 is
updated to say so rather than leaving the doc describing a placement that no
longer exists, and the two admin help strings that told authors "a strip below
the top nav" now describe where a banner actually appears.

`chat-input` is reused by the agent-preview and marketplace test-drive panes,
where a platform-wide notice would read as a bug rather than an announcement.
A `showAnnouncements` input gates it, following the same opt-out shape as the
`show*` controls beside it: default true, explicitly false at those two call
sites, and threaded through `chat-container` so its embedded mode is off too.

The shell mount and its `isAuthenticated()` gate are gone with it — the
composer only exists inside an authenticated chat route, so the 401-on-login
hazard that gate existed for is now structural rather than guarded.

Three real test failures found and fixed on the way: two `chat-container`
specs stub `app-chat-input` and needed the new input added to the stub, and
one of the banner's own assertions was stale after the restyle. Full frontend
suite 2486 passed, only the known `submission-review` flake outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): put the banner on the side the composer leaves free

The composer is not always in the same place. A conversation pins it to the
bottom of the viewport, where a pill below it would be off the edge. The empty
state centres it with the greeting immediately above, where a pill above it
floats over that greeting — visibly so at 375px, where the greeting wraps and
the pill covered its second line.

So the placement follows the composer: `below` on the empty state, `above`
otherwise.

Derived, not measured. `isEmptyState()` is the same computed that already
picks which layout branch renders — the centred composer or the bottom-pinned
one — so reading it makes the two impossible to drift apart. Measuring the
composer's viewport position would re-derive that same fact less reliably and
would have to be recomputed on resize, on scroll, and when the artifact pane
opens.

The banner takes a `placement` input and swaps `bottom-full`/`mb-2` for
`top-full`/`mt-2`; `chat-container` supplies it through `chat-input` alongside
the `showAnnouncements` gate.

Verified on the empty state at desktop and 375px: the pill sits below the
composer in clear space and `coversGreeting` is false in both, where it was
true before. Full frontend suite 2490 passed — a clean run, including the
`submission-review` spec that has been flaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(artifacts): add UserArtifactsIndex and backfill the keys it needs

Groundwork for serving the library's user-wide listing from an index
instead of a base-table Query. Nothing reads it yet — the query switch
ships separately, for the deploy-ordering reason below.

## Why an index at all

The base table is already partitioned by user, so this was never about
reachability: `list_for_user` can Query PK=USER#{uid} today. It is about
what that Query has to read. HEAD and version rows share the partition,
so it spans roughly 3x the rows it returns, then filters and date-sorts
them in memory — which is also what makes it unpaginable, since the page
boundary would fall in the wrong place. Only HEAD rows carry GSI2PK, so
the index holds one row per artifact, already newest-first.

## The backfill is the load-bearing half

`UserArtifactsIndex` is sparse. A HEAD row without GSI2PK is not stale
in it — it is absent from it forever, silently. The writer began
stamping the keys on 2026-09-04; every row written before that has
neither attribute, so switching the query without backfilling would drop
every older artifact from its owner's library with no error anywhere.

`backfill_artifact_user_index_keys.py` stamps them. Dry-run by default,
idempotent (`attribute_not_exists(GSI2PK)`, so it also yields to the
writer), and it never resurrects a row deleted mid-run
(`attribute_exists(SK)`).

It reads `updated_at` to build GSI2SK and never assigns it — that
attribute is embedded in both GSI sort keys and is writer-owned, the
same restraint `rename` observes. A HEAD row lacking `updated_at` is
reported by name rather than stamped with a fabricated timestamp that
would sort it wrongly forever.

Run on dev: 22 HEAD rows, 22 stamped, 0 skipped, 0 failed; re-run
reported 22 already-stamped, 0 stamped. Verified independently
afterwards: 0 rows missing GSI2PK, 0 version rows wrongly stamped, and
GSI1SK == GSI2SK on all 22 — GSI1SK is the *writer's* own
`ARTIFACT#{updated_at}#{aid}`, so that agreement checks the backfill's
format against production-written data rather than against a reading of
the code.

## Deploy notes

Adding this is one `UpdateTable`, and only one GSI may be added per
`UpdateTable` — the committed `gsi-inventory.json` gains exactly one
line here, so no release split is needed.

CFN reporting UPDATE_COMPLETE does NOT mean the index is ACTIVE;
DynamoDB backfills it asynchronously. That is why the query switch is a
separate PR: platform.yml and backend.yml share a concurrency group but
their order is not enforced, so shipping both together risks app-api
querying an index that does not exist yet.

Infrastructure: 784 passed. Backend: 186 passed across the artifact
suites, including 10 new tests for the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sidenav): show the skeleton, not "No Chats Yet", while sessions load

`isLoading()` tested `sessionsResource.value() === undefined`, which stopped
being true during the cold-start fetch.

The loader short-circuits to `null` when `sessionsRequest` is false, so the
resource *resolves* before any session is fetched. That path is now the
ordinary one: `SessionService`'s constructor only enables loading if the BFF
session is already authenticated, but the service is constructed during the
APP_INITIALIZER pass — `AnnouncementModalService` -> `MessageMapService` ->
`SessionService` — and Angular's `runInitializers` invokes every initializer
synchronously before awaiting any of them, so `bootstrap()` is still in flight
and `isAuthenticated()` is false. The auth effect enables loading afterwards,
but `reload()` preserves the previous value, so `null` survives the whole real
fetch. `isLoading()` read false, `groupedSessions()` was empty, and the sidebar
rendered the empty state until the response landed.

`isLoading()` now means "nothing to draw yet": no API response (`undefined`
before the first load resolves, or the short-circuit `null`) and no locally
cached rows. An empty `sessions` array is a real response and still falls
through to the empty state. `error()` is checked first — reading `value()` on
an errored resource throws, which the old ordering walked into.

Deliberately not keyed on `status() === 'reloading'`: `refreshSessions()`
reloads after send/rename/mark-unread, and that would flash the list to a
skeleton every time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(artifacts): default the share inbox on, with a kill switch

The "Shared with you" inbox shipped default-off and opt-in in 1.18.0, and
the flag documented exactly why: "the surface it gates lands before the
product decision about it does." That decision has now been made — the inbox
is live — so the reason for the inversion is spent.

Carrying an opt-in default past it has a real cost for a repo that is forked:
every institution deploying this stack would silently lose a finished feature
and have to discover a variable to get it back. Default-on is the right answer
for a fork, and "false" still turns it off for anyone who wants it dark.

Flipped in all four places that encode the default:

- feature_flags.py: `== "true"` -> `!= "false"` (house style, matching
  announcements_enabled / scheduled_runs_enabled)
- config.ts: the opt-in ternary -> the kill-switch ternary, `?? false` ->
  `?? true`, same shape as scheduledRuns
- platform.yml and app-api-service-construct.ts: the comments that told a
  reader the opposite

The empty-string case is now load-bearing in the other direction: an unset
GitHub Actions variable forwards "", which must resolve to ON. That is the
single most likely way to ship this flag silently disabled, so it is pinned
by test on both sides of the boundary — and the backend tests are
mutation-checked (reverting the flag fails exactly the two new ones).

Adds CDK config coverage for this flag, which had none at all: unset, empty,
"false", "true", and the cdk.json context path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(announcements): open the full announcement from the banner text

The banner pill renders no body, so its only affordances were a ✕ and an
optional external CTA — which trains people to dismiss unread, with What's
New (buried in the user menu) as the only other route to the content.

The text is now a button that opens the announcement's own dialog, and the
dialog owns the ack from there: any of its exits writes `dismissed` (or
`acknowledged`) and retires the pill durably. Having read the body is a
stronger signal of consumption than clicking ✕ on a one-line strip, and
leaving the pill up afterwards would only ask the user to dismiss something
they have already dealt with.

It opens the single-announcement dialog rather than the What's New list
deliberately: the pill named one thing, and handing back a list to search
through is a worse answer than the thing itself.

Three supporting changes:

- `AnnouncementModalData.sourceSurface` (default `modal`) attributes every
  ack to where the gesture happened, so reach stats can tell a banner that
  earned a read from an interruption nobody asked for. No backend change:
  `AnnouncementSurface` is a plain literal and the ack route never
  cross-checks it against the announcement's own `surfaces`.
- `AnnouncementModalService.openFor()` is the one entry point for a
  user-initiated open. It skips the §D8 turn-safety gate — a click is not an
  interruption — but still respects `openRef`, and marks the item `shown` so
  the auto-modal cannot re-interrupt with something already read.
- A `requiresAck` announcement gets no ✕ on the pill. Suppression is
  rank-based and covers the banner *and* modal slots alike, so a ✕ there let
  a user retire a compliance notice before the blocking modal ever fired,
  leaving no `acknowledged` record anywhere.

The text button uses an `sr-only` prefix rather than an `aria-label`: the
visible line may be the summary, so a title-derived label would leave the
visible words outside the accessible name (WCAG 2.5.3, Label in Name).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(artifacts): serve the library listing from UserArtifactsIndex

⚠️ MUST NOT MERGE until `UserArtifactsIndex` reports ACTIVE in the
target environment. `DescribeTable`, not CloudFormation — CFN reports
UPDATE_COMPLETE while DynamoDB is still backfilling the index.

`list_for_user` now queries the index (GSI2PK=USER#{uid}, GSI2SK
descending) instead of the base table. HEAD and version rows share the
base partition, so the old Query spanned roughly 3x the rows it returned
and then date-sorted them in memory; only HEAD rows carry the GSI2 keys,
so the index holds one row per artifact already newest-first. The
amplification and the sort both go away, and the ordering now comes from
the store rather than being recomputed per request.

Still returns the whole library in one response, paging the index
internally. Exposing pagination is a bigger change than it looks —
search and the type filter live in the SPA, and a filter that sees only
the loaded page is worse than no filter because it looks authoritative,
so both would have to move server-side in the same change. The index
makes that possible whenever it is wanted.

## Two things this turned up

**The library tests were passing against the old code path.** My first
edit spliced the new method in with inverted slice boundaries, leaving
DUPLICATE `list_for_user` and `heads_for_session` definitions — Python
took the last, which was the original base-table version. The suite went
green while testing nothing new. Caught by asking why tests that should
have needed a GSI passed without one; the fixture had no
GlobalSecondaryIndexes at all.

The fixture now declares the index, so moto raises
ResourceNotFoundException if the query ever stops using it — which is
what makes these tests exercise the index rather than silently falling
back.

**Undated rows would have vanished.** A sparse index omits any HEAD row
without GSI2PK, permanently and silently. Rows predating `updated_at`
cannot carry a real timestamp, so under the first version of the
backfill they were skipped — and would have dropped out of their
owner's library, which `test_undated_legacy_rows_are_returned_and_sort_last`
exists to forbid.

They are now stamped with an EMPTY timestamp segment
(`ARTIFACT##{aid}`). That is not a fabricated time: "#" sorts below
every digit, so read descending the row lands last — exactly where the
old in-memory sort put it. Neither dev nor prod holds such a row today;
this is the defensive branch, and it preserves a contract the tests
already assert.

The pagination stub now yields the NEWER row on page 1, matching what a
descending index does. With the old stub a client-side re-sort would
have passed either way and hidden a broken sort key.

Backend: 942 passed across app_api, architecture and the artifact
writer suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sessions): stop marking completed turns as interrupted

A session's AbortController was created per request but released only by
the Stop button — never on normal stream teardown. So after any turn that
finished on its own, `streamingSessionIds()` kept reporting that session
as in-flight for the life of the tab, and the page-hide attribution
handler signalled `navigated_away` for it on the next refresh, tab close,
or cross-document navigation. One departure marked every session ever
streamed in that tab.

The result is a "Response interrupted" chip plus a Continue button on a
complete answer, and — invisibly — a false `<interruption_note>` prepended
to that session's next prompt and persisted in its history, telling the
model its previous response was cut off and to pick up where it left off.

Measured in prod: 1,678 sessions carry the marker and 1,505 of them (90%)
received it more than 30s after their last message landed; 1,475 of those
are `navigated_away`. Not model-specific and not new — the oldest is
2026-08-21.

Two changes:

* `releaseAbortController` on stream teardown (identity-checked, so a
  superseded stream's late close can't clear its replacement's handle).
  This makes `streamingSessionIds()` mean what its docstring claims.
* `POST /sessions/{id}/interrupt` now verifies "mid-turn" against the
  session's single-flight lease before recording `navigated_away`, rather
  than taking the client's word for it. Old tabs run the old SPA long
  after this ships. `user_stopped` is deliberately not gated: the button
  only exists while streaming, and it also arms distributed cancellation,
  which must still reach a turn whose lease read fails open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(sessions): backfill script for false interrupted-turn markers

One-shot cleanup for the markers the stale-controller bug left behind.
Each one shows a "Response interrupted" chip plus a Continue button on a
complete answer, and puts a false <interruption_note> into that session's
next prompt — persisted in history, invisible in the UI. Markers self-clear
only at the start of that session's next turn, so a conversation nobody
returns to stays armed indefinitely.

Clears only what is provably false: reason `navigated_away`, marked more
than 900s after the session's last message. Gap alone is not enough — an
interrupted continuation persists no assistant message, so `lastMessageAt`
stays at the previous turn and a real interruption can show a modest
positive gap. But the SSE stream times out at 600s, so nothing is still
running 15 minutes later. `user_stopped` and `connection_lost` rows are
never touched: neither comes from this bug, and clearing one would destroy
a real record.

Dry-run by default. Each write is conditional on the exact timestamp and
reason the scan read, so a session re-marked in between is skipped rather
than clobbered; re-running is a no-op.

Prod dry-run (2026-09-06): 1,680 marked rows → 787 provably false, 847
left alone as ambiguous, 46 left alone as another reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sessions): store displayText when the user's turn lands, not at turn end

`displayText` is the clean copy of what the user typed, and what the UI
renders whenever the prompt sent to the model was augmented — RAG context,
attachment guidance, an MCP App context block, or the `<interruption_note>`
addressed to the model after an interrupted turn.

It was written by a single call site in the stream coordinator's success
path, as the last thing a turn does. Nothing on the Stop, disconnect, or
error paths wrote it, so any turn that didn't reach that line left the raw
augmented prompt as the only thing the UI could render. Two effects, both
reproduced on dev:

  * transiently, for every augmented turn — reload while it is still
    streaming and the note is in the user's own chat bubble;
  * permanently, for any turn that never completes. Stop it and the `D#`
    record is never written, so the note stays in the bubble on every load.

The permanent case lands where it hurts most: a turn only carries an
interruption note because the PREVIOUS turn was interrupted, so the note is
disproportionately likely to be on a turn that gets interrupted too. Not
model-specific — reproduced on GPT-5.6 Luna and Claude Haiku 4.5.

`DisplayTextHook` writes the record on `MessageAddedEvent` instead — the
moment the user's message enters history, before the model call — so every
later exit path already has it. Writing at request start instead would risk
a record keyed to an index the failed turn never filled, which a later
message would inherit; anchoring to the append keeps index and record
together.

Two subtleties the hook has to respect:

  * Not every role-`user` message is the user. Tool results carry that role,
    and Strands prepends a SYNTHETIC tool-result message ahead of the prompt
    when history ends on a dangling `toolUse` — exactly what an interrupted
    tool turn leaves behind. Content carrying `toolResult`/`toolUse` is
    skipped so the arm is spent on the user's own message.
  * The agent instance is cached across turns (#741/#751), so the hook is
    armed unconditionally at the head of every turn, including to None.

The coordinator's write stays as a backstop for wrappers that carry no hook
(voice, tests) and for a failed hook write, gated on `wrote_this_turn` so the
normal path still makes exactly one put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp-apps): keep tool-result content on app-initiated tools/call

`_serialize_content` read the result's content with `getattr`, but
Strands' `MCPToolResult` extends `ToolResult`, a TypedDict — so what
`call_tool_sync` returns is a plain dict at runtime and the attribute
lookup found nothing. Every app-initiated tools/call therefore relayed
`content: []` back to the iframe.

The failure was silent end to end: app-api returned 200, inference-api
returned 200, and the MCP server had really run the tool, so a write took
effect while the App received nothing to render. Any MCP App that
re-reads state after an edit appeared frozen.

Handle the dict shape alongside the attribute one, mirroring the
`isinstance(result, dict)` branch `_is_error` already has.

The existing fakes in the dispatch tests are objects carrying a
`.content` attribute, which is why the attribute-only path looked
correct; the added test uses the dict shape the client really returns,
with untagged Strands content blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Phil Merrell <philmerrell@boisestate.edu>

* fix(mcp-apps): revive a torn-down MCP session for app-initiated calls

An app-initiated tools/call arrives between turns. `routes.py` rebuilds the
conversation's agent first, but with `cache_write=False` it reads a cached
agent — and Strands tore that agent's MCP client sessions down when the
turn that built them ended. `_resolve_client` then hands back the client
the UIToolCatalog recorded at some earlier build, so `call_tool_sync`
raises:

    MCPClientInitializationError: the client session is not running.

which becomes AppToolCallError(502) and reaches the App as a 502 Bad
Gateway. It presents as intermittent because a call made while the turn is
still streaming finds the session alive.

Wrap the call so the client is reconnected for its duration and left as it
was found. A session that is already live belongs to an in-flight turn and
is used as-is, never stopped here. Overlapping app calls against the same
client share one revived session through a refcount, so no call has the
connection closed underneath it.

Note `_resolve_client` takes `agent` and does not use it; resolving the
live client from the freshly built agent would be the deeper fix, but it
reaches into how tool providers are held and cached. This keeps the blast
radius at the dispatch boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Phil Merrell <philmerrell@boisestate.edu>

* fix(kb): engine-aware managed context cap (8,000) — task 16.1

The 2,000-char MAX_CONTEXT_CHARS was sized for Docling chunks; Bedrock's are
~3x larger, so on the managed backend only ~1 of top_k=5 reranked chunks
cleared the cap — top_k=5 became top_k=1 at the model, producing materially
wrong answers (HANDOFF §5.40, e.g. a Major-Core course called an elective).

Add resolve_context_cap (managed 8,000 / legacy 2,000) keyed on the same
resolve_engine_for the backend resolver uses, wire both retrieval call sites,
and pin the split with mutation-tested guards in test_kb_backend_parity.py.
Amend Requirement 3.2 (was 2,000 on both) to an engine-aware cap: the asymmetry
restores parity in chunks-reaching-the-model, not characters, sized from eval
§13.6 (8,000 = all five managed chunks fit, ~966 extra input tokens/turn).

Validated end-to-end on a prod-derived KINES advising corpus re-created in dev
via scripts/local-dev/kb-cap-benchmark.py.

* fix(kb): fail closed when no chunk carries a document_id — task 16.3

_filter_vectors_by_document_status opened with `if not doc_ids: return vectors`
— the one fail-OPEN line left in an otherwise fail-closed function (§5.33). A
non-empty batch where every chunk's document_id was absent/empty bypassed the
DynamoDB status check and was served unverified, including deleted content.

Now returns [] and emits METRIC_STATUS_FILTER_FAIL_CLOSED like the other
unprovable paths; an empty input stays an empty result with no metric. Guard
test_filter_fails_closed_when_no_chunk_carries_a_document_id, mutation-tested.
Requirements 5.1/5.2 already mandated this; the path was overlooked.

* docs(kaizen): queue Strands Snapshots + AgentCore workspaces, Unlocks-led

Two entries added to the kaizen review queue, both framed on the
capability-unlock lens rather than subtraction:

- AgentCore Runtime workspaces: filesystemConfigurations on
  CreateAgentRuntime (sessionStorage / s3FilesAccessPoint /
  efsAccessPoint / capacityProviderVolume), verified against pinned
  botocore 1.43.68. Recommends shipping the s3FilesAccessPoint bridge
  over the existing user-files layout and deferring sessionStorage
  behind the deletion-path and durability gates.

- Strands Snapshots: already present in pinned strands-agents 1.51.0.
  Led by branch/regenerate — a capability the SPA does not have at all
  — with the four-candidate subtraction audit recorded as a labelled
  negative result so it is not re-investigated.

Both carry the dual-lens correction Phil made on 2026-05-10, which was
prompted by this same AgentCore filesystem feature being written up
subtraction-first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: retain MCP App UI resources per conversation

Leaving a conversation with an MCP App and navigating back dropped every
App frame to a plain tool card until a hard refresh.

Two things collided. `session.page` reset `McpAppStateService` on every
route change, and the only thing that re-seeded it — the `uiResources`
sidecar on `GET /messages` — rides on a request that
`loadMessagesForSession` deliberately skips once a conversation's messages
are cached. Since `MessageMapService` never evicts, the second visit to a
conversation always hit that short-circuit, so the reset had no way back.
A refresh worked only because it destroyed the message cache.

Re-key the registry `sessionId -> toolUseId -> resource` and retain every
conversation for the SPA session, mirroring the message cache. Reads are
scoped to the viewed conversation, so a `toolUseId` can only resolve inside
the conversation that produced it. Iframe teardown is unaffected: frames
unmount with their message-list components.

Also drop the `isViewedSession` gate on `onUiResource` /
`onToolInputPartial` and record under the streaming session's own id. That
gate existed only because of the reset; with retention it became harmful —
an App produced by a conversation streaming in the background would have
been discarded for good, since the inline `ui_resource` event never
re-streams and the persisted replay rides on the request navigate-back
skips.

Verified against the dev backend: navigate-away-and-back keeps the frame
(with no `GET /messages` on the return trip, confirming the mechanism),
hard refresh still hydrates, two App conversations retain independently,
and an App produced while its conversation streamed in the background is
present on return. Tool rails, artifacts and app-initiated cards unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: pin the frontend preview to port 4200

The local app-api builds its CORS allowlist explicitly from `CORS_ORIGINS`
with `allow_credentials=True` (which forbids a wildcard), and the Cognito
localhost callback is registered for `http://localhost:4200`. A preview
moved to any other port therefore has every API call blocked and cannot
complete a login — a failure that surfaces as a broken app rather than a
port conflict.

`autoPort: false` makes the preview fail loudly on a busy port instead of
silently relocating to one that cannot work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp-apps): resolve the OAuth token for app-initiated tool calls

An App-initiated `tools/call` runs the MCP client directly instead of the
agent's tool loop, so `BeforeToolCallEvent` never fires and
`OAuthConsentHook` — the only thing that warms `oauth_token_cache` — never
runs. The client's token provider is just a cache read, so on any container
that has not served a model-driven turn for that (user, provider) the
request goes out with no Authorization header at all: after a page reload
lands the call on a fresh runtime (or a restarted local uvicorn, or once the
cache's 3000s TTL lapses) every button in an embedded App fails.

It fails silently rather than 401-ing because a server that accepts an
unauthenticated `initialize`/`tools/list` — Google Tasks does — still
registers the tool, so the App renders and only the calls fail, with the
server's own "isn't connected yet" text. No 401 means
`_recover_oauth_preflight`, which does warm the cache from the vault, never
fires; a server that 401s its `tools/list` would have self-healed.

`_ensure_oauth_token` now repeats the hook's warm-the-cache half explicitly:
resolve the provider from the MCP client, honour the durable disconnect
flag, then `resolve_token_or_consent_url` to warm the cache or report that
consent is required.

Two deliberate departures from the hook:

* Consent-required answers 409, never 401. The SPA's error interceptor
  treats any 401 as an expired BFF session and redirects to login, so a 401
  would sign the user out over an unconnected connector. 409 is already this
  codebase's "needs connecting" status (file-source browser, export dialog).
* An auth-shaped failure clears the cached token but does not retry. An app
  call is whatever button the user pressed — `complete_task` — so a
  regex-triggered retry could apply a mutation twice. The next press misses
  the cache and re-resolves.

The auth-failure regex moves to `apis/shared/oauth/auth_failure.py` so the
hook and the dispatch cannot drift on what an auth failure looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: collapse app-run tool cards behind the App frame's header

App-initiated tool calls (MCP Apps PR #6) hydrated on reload as one static
card apiece, stacked at the tail of the conversation. An interactive App
like the Google Tasks board runs a tool on nearly every gesture, so a
refresh produced a wall of "RAN BY APP" cards — detached from where they
happened, duplicating state the re-mounted App already shows, and growing
without bound.

They now surface where they belong: on the App frame that ran them,
grouped by the originating tool-use id the card store already records.
The header carries a count chip ("3 actions", tinted and annotated when
something failed); expanding it shows successes collapsed into a single
`board_snapshot ×6, update_task ×2` summary line, with each failure listed
separately alongside its error text — failures being the question this
record exists to answer.

Cards whose frame can't render (no mcp-sandbox origin → no frame) would
otherwise vanish silently, so the message list keeps a fallback box for
those orphans, summarized the same way.

Provenance-only, as before: nothing here reaches the model or the prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(mcp-apps): revalidate an App's UI resource when the agent is already up

The App HTML the SPA re-mounts on reload is whatever `resources/read`
returned when the tool first ran, replayed verbatim from its `UIRES#` row
along with the CSP and permissions captured at the same moment. A server
that ships a new App version — or tightens the policy its App runs under —
never reached conversations that already existed, and we had quietly become
the durable store of record for a resource that belongs to the server.

Re-reading needs a live MCP client, and the only path to one is a built
agent. Revalidating on conversation open would therefore add a full agent
rebuild (76% of sessions bypass the agent cache) to a page load that runs no
model turn, for every App whether or not anyone touches it. So this
piggybacks instead: an app-initiated tools/call has already built the agent
and revived the client, which makes the extra read close to free.

The refreshed shell lands on the next load rather than the current one.
That is the deliberate trade — it converges for the Apps people actually
use, and costs nothing for the ones they don't.

Bounded and non-blocking: one refresh per resource per process, dispatched
off the response path so the App's call is never slowed, and silent on
every failure — a server that is down must not blank an App that still
works. `get_provenance` projects only the producing tool name and the
message anchor, never the stored HTML the refresh is about to replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp-apps): give an App a real chance to flush before teardown

SEP-1865 has the host send `ui/resource-teardown` before tearing a resource
down for any reason, and wait for the response where it can, so the App can
save its state to its own server. That matters more here than it looks: the
host is deliberately not the store of record for App state, so a teardown
the App never hears about is state nobody saves.

`dispose()` defeated exactly that. It fired the notification and then, in
the same tick, removed the message listener and rejected every pending
request — so the ack landed on nothing, and an App that answered teardown
by calling a save tool had its postMessage dropped on the floor. The
comment said "we're going away regardless of the ack", which was true and
was the bug.

Two changes:

* The bridge stays attached through a grace window after sending teardown,
  and `dispose()` returns a promise that settles on the ack or when the
  window expires. Inbound routing now gates on a new `detached` flag rather
  than `disposed`, so the window is live on purpose. The App's save call is
  proxied over HTTP from the host page, so once its message reaches us the
  request outlives the iframe.

* Teardown fires at navigation intent, not just component destroy. By the
  time Angular destroys the frame it is removing the iframe in the same
  tick and the View is gone before it can run anything, so a new registry
  of live bridges lets the conversation-change path notify every open App
  while its iframe is still alive. Fired, not awaited: the value is in the
  timing rather than the wait, and that …
@philmerrell
philmerrell merged commit 9bc68d3 into develop Sep 14, 2026
6 checks passed
@philmerrell
philmerrell deleted the backmerge/main-into-develop-1.21.0 branch September 14, 2026 04:40
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