Skip to content

Backmerge main into develop (1.15.0) - #878

Merged
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.15.0
Aug 17, 2026
Merged

Backmerge main into develop (1.15.0)#878
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.15.0

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Reconciles main back into develop after the 1.15.0 squash-merge (#877).

⚠️ Merge with a MERGE COMMIT, not a squash

The whole point is to make main a genuine ancestor of develop. Squashing recreates the divergence and the release-artifact conflicts come back next release.

Contents

Clean auto-merge, no conflicts. Only the release artifacts crossed over — develop already had every feature commit, and main only added the version bump and the two release documents on top:

  • VERSION 1.14.1 → 1.15.0
  • CHANGELOG.md, RELEASE_NOTES.md
  • README.md badge + current-release line
  • Manifests and lockfiles: backend, frontend, infrastructure, tui

Verified

  • git merge-base --is-ancestor origin/main HEADPASS
  • sync-version.sh --checkPASS
  • Both documents lead with 1.15.0, previous entries untouched
  • No feature-code paths in the diff

🤖 Generated with Claude Code

philmerrell and others added 2 commits August 17, 2026 14:14
* fix(marketplace): the agent detail page never loaded its agent

`AgentDetailPage` called `load()` from its constructor, and `load()` reads
`this.id()` — an `input.required<string>()` bound by `withComponentInputBinding()`.
The router sets that input *after* construction, so the read threw NG0950 before
`AgentApiService.getAgent()` was ever called.

The throw landed inside `load()`'s own try/catch, which turned it into
`this.error.set('Failed to load this agent.')`. So the page rendered a plausible
error banner with **no HTTP request behind it** — no console error, no failed
request, nothing to grep for. `/agents/:id` has been in this state since phase 3
(66f38346); the detail page has never successfully loaded in a browser.

Moved both init calls to `ngOnInit`, which runs after input binding.

Found while running the phase 5-7 dev smoke test, where the detail page is the
only surface carrying the pin control. There is no `agent-detail.page.spec.ts` at
all, which is why CI never caught it — a regression spec is tracked separately.
Note that a spec asserting this must set the input the way the router does (after
creation, e.g. via `RouterTestingHarness`); passing `id` at construction time
reproduces neither the bug nor the fix.

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

* test(marketplace): pin the agent detail page's load path

`AgentDetailPage` had no spec, which is the only reason the phase 3 bug
(`load()` in the constructor, fixed in f60951b1) reached users: the page
rendered "Failed to load this agent." with no HTTP request behind it, and
nothing in CI looked.

Five tests over the load path. The two that would have caught it assert that
`AgentApiService.getAgent` is called with the bound id, and that the error
banner is absent on the happy path — deliberately not assertions about the
rendered agent, since a zero-request page is what the bug actually produced.

⚠️ The component is routed through `RouterTestingHarness` with
`provideRouter(routes, withComponentInputBinding())` rather than constructed
directly. That is load-bearing, not ceremony: `id` is an `input.required` the
router sets *after* construction, so handing it in at construction time makes
every assertion here pass against the broken code too. Verified both ways —
red against the constructor form (`getAgent` called 0 times), green against
the fix.

Services are stubbed via DI tokens rather than `vi.mock`, per the repo
convention on cross-spec mock pollution.

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

* test(marketplace): pin the agent detail page's load path

`AgentDetailPage` had no spec, which is the only reason the phase 3 bug
(`load()` in the constructor, fixed in f60951b1) reached users: the page
rendered "Failed to load this agent." with no HTTP request behind it, and
nothing in CI looked.

Five tests over the load path. The two that would have caught it assert that
`AgentApiService.getAgent` is called with the bound id, and that the error
banner is absent on the happy path — deliberately not assertions about the
rendered agent, since a zero-request page is what the bug actually produced.

⚠️ The component is routed through `RouterTestingHarness` with
`provideRouter(routes, withComponentInputBinding())` rather than constructed
directly. That is load-bearing, not ceremony: `id` is an `input.required` the
router sets *after* construction, so handing it in at construction time makes
every assertion here pass against the broken code too. Verified both ways —
red against the constructor form (`getAgent` called 0 times), green against
the fix.

Services are stubbed via DI tokens rather than `vi.mock`, per the repo
convention on cross-spec mock pollution.

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

* test(inference): pin the #741 conversation fork with a strict xfail

An `@`-mention turn and the plain turns around it run on two different cached
`Agent` instances, and neither sees the other's messages. The user sees one
continuous thread — the SPA renders from the persisted store — but the model
answers "NOT IN HISTORY" about a turn on screen. Measured on dev session
e5e8b259-1780-4179-8ebe-38c57d3709a5.

The test models the round trip that produces it: plain → mention → plain, with a
`store` standing in for AgentCore Memory. The distinction it has to preserve is
why the bug is invisible in production logs — the mention turn is a cache MISS,
so it restores and legitimately sees prior history; the plain turn after it is a
cache HIT, restores nothing, and is stale. A first draft asserted the mention
turn was empty, which fails for the wrong reason and would have passed once
anyone made a fresh agent restore.

Asserts on the conversation rather than instance identity, so it stays valid
whichever fix wins: reuse one instance, hand the message list between instances,
or re-restore on a stale hit.

`strict=True` so it fails loudly the moment the fix makes it pass.

Refs #741

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

* fix(inference): one session is one conversation, whatever agent runs a turn

The agent cache keys on *configuration* — system prompt, tools, model, skills —
which is right: those need different `Agent` objects. The conversation is not
configuration, and nothing enforced that.

So an `@`-mention forked the thread. The mention turn missed the cache, built a
second agent, restored history and looked fine; the next plain turn reverted the
key and cache-*hit* the original instance, whose in-memory list still ended
before the mention. `initialize()` never re-runs on a hit, so the stale list won
silently: the model answered "NOT IN HISTORY" about a turn the user could see on
screen, because the SPA renders from the persisted store. Symmetric, too — a
second mention could not see the plain turns in between.

`_adopt_session_conversation` points a newly built agent at the list its session
is already using.

⚠️ Shared by reference, deliberately. Copying would fix only the direction that
already works — the miss. The turn that goes stale is a cache *hit*, where
nothing runs and there is nothing to copy. Aliasing is what makes the mention
turn's appends visible to the instance the next turn hits. Safe because every
site that rebinds `agent.messages` (document stripping, content-block sanitizing,
compaction slicing, pairing repair) lives inside
`TurnBasedSessionManager.initialize()` and runs before adoption; after
construction the list is only appended to.

Re-restoring on a stale hit was the alternative and is worse: restored history
passes through the sanitizers and pairing repair while accumulated history does
not, so one conversation can serialize two ways depending on the path — a prefix
byte change on an arbitrary turn, which is what the prompt-cache contract
forbids. Aliasing re-serializes nothing, so the cached prefix is untouched.

Adoption runs before the `extra_tools` early return: an uncached agent still
takes a turn in the thread and must not fork it. A length guard keeps a live
instance that trails Memory from dragging a newer restored history backwards;
the reverse comparison would be wrong, since compaction legitimately shortens a
restored list.

Concurrency is covered by the single-flight session lease — one turn per session
at a time. Separate replicas share no cache, so cross-process divergence is
unchanged by this either way.

Fixes #741

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

* docs(marketplace): a mention costs one prefix re-write, not two

The phase 7 notes predicted "two prompt-cache prefix re-writes ... roughly
$0.25 per mention round-trip". Measured on dev, the outbound leg is a genuine
full re-write but the swap-back is a cache HIT: base toolConfig and system
prompt revert byte-identically and read from the still-live pre-mention entry,
so only the mention exchange is written as a delta.

  mention          miss_avoidable   read 0      write 4301
  next plain turn  hit              read 2720   write 138

At a 50k prefix and Sonnet 5's $2.30/MTok write premium that is ~$0.12 per
mention, about half the original figure.

Two caveats now recorded with the number, because both change it materially:
the swap-back only hits inside the ~5-minute TTL, and an Agent that pins its
own model lands in a different cache namespace entirely, where it can hit
nothing and its write is never re-read.

⚠️ Also records why the number was wrong twice over. The first measurement was
taken against #741, where the swap-back looked *cheaper* than the truth (write
71, not 138) because the stale agent was omitting the mention exchange from the
prefix — a broken run flattering us while the spec's prediction erred the other
way. Re-measure after any invocation-path change rather than trusting either.

Refs #741

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

* fix(observability): measure the cache TTL from the same-prefix predecessor

`classify_cache_status` decided "avoidable" vs "TTL expired" using the gap to
the immediately-previous call row. That is the wrong clock the moment two
prefixes interleave in one session — which is exactly what an `@`-mention does.
The entry a call could have HIT belongs to the last call with the *same*
prefix, and that is not always the last call.

Measured on dev during the #741 verification: a plain turn 266s after a mention
was classified `miss_avoidable` and booked $0.006256 of waste, but the entry it
needed was written 308s earlier, past the 300s TTL. The re-write was
unavoidable. In that six-turn session three rows were `miss_avoidable` and none
were the bug the metric exists to catch.

The fingerprints needed to do this correctly were already on the row. The
lookup now reads a small window of recent rows instead of one, and classifies
against the newest row sharing this call's toolConfig + system-prompt hashes.
No new data, no schema migration — only a wider read of rows already indexed.

Three behaviours worth keeping:

- When no same-prefix predecessor is in the window we classify as
  `miss_ttl_expired`, not `miss_avoidable`. Deliberately the conservative
  direction: under-reporting waste keeps the metric trustworthy, whereas crying
  wolf is what made it useless.
- Calls with no fingerprints (hook disabled, non-Bedrock provider) fall back to
  the previous call exactly as before, so nothing regresses where the fix
  cannot apply.
- `previous_cached_prefix_tokens` still comes from the previous call when there
  is no match, so the below-threshold `first_write` guard keeps working.

`cacheGapSeconds` keeps its original meaning — plain chronology, which existing
consumers read. `cachePrefixGapSeconds` is added only when the deciding call was
an older one, so a status that looks inconsistent with the visible gap explains
itself rather than reading as a bug. Surfaced through the admin costs API and
rendered beside the gap on the session anatomy page.

Fixes #753

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

* feat(marketplace): GA the Agent store — the nav gate was the only closed door

D14 called for the kill switch plus an `agent-marketplace` RBAC capability
that 404s the routes for ungranted roles. The capability was never built, and
should not be: the admin roles UI builds `grantedTools` from the tool catalog
with no free-text entry, so a feature-capability id cannot be granted from the
UI at all — only by hand-writing DynamoDB items. That is why the `skills` gate
was removed and why `scheduled-runs` 403'd in prod and was dropped. Building it
a third time ships a gate nobody can open.

What was left behind was worse than either end state. One template condition,
`@if (showAgents() && isAdmin())`, hid the nav entry — while `/agents/discover`,
`/agents/:id` and `/agents/pinned` carried `authGuard` only, the composer
`@`-mention menu had no admin check at all, and a role-seeded pin (D9) pushed
Agents into a member's Pinned tab unprompted. The store was reachable by any
authenticated user through three doors while hidden behind the one we
controlled; nobody could answer "who can see this?" without reading four files.

So: drop `isAdmin()` from the nav condition and make the kill switch the only
lever. The Preview badge stays until Assistant deprecation (#746) lands, since
until then the sidenav ships both nouns.

The regression spec renders the real template rather than reading `showAgents()`
— the bug lived only in the `@if`, so a spec asserting the computed passes
against both the gated and the GA'd code. Verified to fail against the old
condition and only against it.

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

* feat(marketplace): mark published Agents whose behavior changed after approval

Three individually-reasonable v1 non-goals compose into a governance hole none
of them was evaluated against: D2 does not re-review edits, there is no agent
versioning, and D9 lets an admin *lock* a role-seeded pin so members cannot
remove it. So an admin locks an Agent into every member of a role's sidebar,
the author rewrites its instructions, and the new behavior is live immediately
for everyone — no re-review, no history, no notification, and no opt-out for
the affected user, whose own dismissal loses to the lock by design.

Approval now records a SHA-256 of the instructions it approved, and the admin
Listings table marks any published listing whose instructions no longer match.
This is not a gate — it is the curator's reason to look.

The marker reports two claims and deliberately does not merge them:

  instructions  behavior definitely changed   measured (hash mismatch)
  edited        record changed, cause unknown inferred (updatedAt > reviewedAt)

The issue proposed the timestamp comparison alone as a pure read. It is not
sufficient on its own: an admin's own D13 presentation edit bumps updatedAt
without touching reviewedAt, so a timestamp-only marker fires on the admin's
own typo fix, and a governance marker that cries wolf is one that gets
learned-ignored. But the hash alone would be blind on every listing approved
before it shipped — precisely the already-published, possibly-locked back
catalogue this is about. So: hash when present, timestamp as the explicitly
weaker fallback, styled and worded so the two never read alike.

The baseline is a hash *of* the instructions, so it rides the same viewer gate.
`_agent_response` already drops `instructions` for anyone below editor; it now
drops the hash with it. Not reversible on its own, but it would confirm a
guessed prompt for anyone who could produce one, which is what that gate exists
to prevent. The admin listings projection is unaffected.

Both new suites were verified to discriminate: with a timestamp-only
derivation the admin-edit test fails with 'edited' is None, and without the
viewer gate both hash-exposure tests fail while owner/editor keep passing.

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

* fix(spa): stop cross-file state leaks that made ng test flaky

On any `ng test` run exactly one randomly-chosen spec file failed; the
next run passed it and failed a different one instead.

The unit-test builder hardcodes `isolate: false` in its vitest runner, so
every spec file assigned to a worker shares one module registry, one
jsdom, and one timer implementation. Worker assignment is timing
dependent, so which specs share state changes run to run — hence the
random victim.

The trigger was leaked fake timers. `vi.restoreAllMocks()` restores
neither fake timers nor `vi.stubGlobal`, and several specs relied on it
for both. A spec left the clock frozen and the next spec in that worker
to await anything real-timer-driven — a RouterTestingHarness navigation,
an HttpTestingController round-trip — died with "Test timed out in
5000ms", which is how every observed failure presented.

Fixed at the source in seven specs:

  sidenav, toast, citation-display  fake timers never restored
  model.service                     stubGlobal needs unstubAllGlobals
  session.service                   window.location + document.cookie
                                    replaced and never put back
  admin-cost-state, admin.guard     unrestored spies on
                                    document.createElement and console

Added a backstop afterEach in test-setup.ts (useRealTimers +
unstubAllGlobals + unstubAllEnvs) so a spec added later cannot
reintroduce the class of bug, and global-hygiene.spec.ts as a canary
asserting the shared jsdom is intact. A global restoreAllMocks was
deliberately not added — it would reset vi.fn() implementations and
break specs that build mocks outside beforeEach.

Also fixes a second, unrelated flake found while verifying: app.spec.ts
loads the root component through a runtime dynamic import that measures
~4s against vitest's 5000ms default, leaving ~20% headroom before load
pushes it over. Raised to 30s, matching the existing precedent in
shared-view.page.spec.ts.

Verified with a harness that builds the full spec set and varies only
execution order (subsetting with --include changes bundling and masks
the bug): 16 seeded orderings including the four that previously failed,
5 plain runs, and 3 runs under 8 CPU hogs on 10 cores — all green.

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

* feat(agents): redirect the Assistant editor to the Designer, one noun in the nav

Designer Phase 5, step 3 and the user-facing half of step 4. Marketplace D1 is
"one noun: Agent", and the sidenav has been shipping both.

Steps 1-2 were already done: `compat.to_agent_view` renders a legacy Assistant
*as* an Agent, and the Designer passed parity some time ago — it carries model
config, tools, skills, memory spaces, tags and visibility on top of everything
the old editor had, and reuses that editor's own knowledge-base section and
share dialog for the rest. The old editor had strictly less to offer for the
same record.

`/assistants`, `/assistants/new` and `/assistants/:id/edit` become `redirectTo`
entries rather than deletions. Those paths are in bookmarks, in the "edit" link
of every old chat session, and in links people shared with each other. The ids
are identical on both sides — nothing was migrated — so a redirect lands on
exactly what the old URL opened. Deleting them would 404 all of that for no gain.

The nav ships one entry, and the Agents "Preview" badge came off with the second
noun it existed to disambiguate.

The term pass is deliberately not a find-and-replace. "You are a helpful
assistant that…" stays as the instructions placeholder: that is the conventional
system-prompt idiom, and rewriting it to "agent" would be worse prompt guidance,
not better terminology. What changed is the words naming our own product concept
— nav, session indicator, share dialog, settings.

⚠️ This changes what AGENTS_API_ENABLED means. While both nouns shipped, off
degraded to the Assistants editor; there is nothing left to fall back to, so off
now means no authoring surface at all. It is an outage switch, not a feature
toggle. Recorded in the flag docstring and the spec rather than silently
changed — worth a deliberate decision about whether the flag should survive.

The old editor's components are now unreachable but not yet deleted; retiring
them (and the docs pass) is the rest of step 4, kept separate so this flip stays
revertible in one line.

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

* chore(agents): retire the Assistant editor, keep what the Agent surface consumes

Designer Phase 5, step 4 — the rest of #746. #758 made these pages unreachable;
this deletes them.

Gone: assistants.page, assistant-form.page, assistant-list, assistant-preview,
and the assistant-form barrel.

`assistants/` itself stays, and is no longer a feature — it is the set of pieces
the Agent surface consumes: the share dialog, the assistant card, the
knowledge-base dialogs and services, PreviewChatService, and the models. Each was
verified to have a live inbound reference from `agents/`, `session/` or
`knowledge-base/` before being kept. It keeps the name because the record is
still an Assistant on the wire — ids are identical and nothing was migrated, so
renaming the folder would drag the API contract's vocabulary with it. The README
is rewritten from a stale description of deleted files into that consumer map,
and says plainly that new UI belongs under `agents/`.

The backend `/assistants/*` surface is untouched and not deprecated: `test-chat`
and the document sub-routes are called by the Designer's own preview pane and
knowledge-base section.

Deleting `assistant-preview.component.spec.ts` would have quietly dropped
coverage from a live surface, because `agent-preview` — the component that
replaced it — had no spec at all. Ported the tests that still apply, and pinned
the one behavior that differs: the Agent preview sends no live instructions and
opts out of client-side prompt and tool injection, because an Agent resolves
those server-side from the saved record and a long persona would exceed the
system_prompt cap (422). That looks like a bug until you know why, so it now has
a test with the reason attached. Also pinned the model lock, including its
release on destroy — the lock lives in the root ModelService, so leaking it would
follow the user out of the Designer into an ordinary chat.

The docs pass found less than expected and deliberately changed nothing in
docs/SSE_ERROR_MESSAGING.md, the testing report, or the root README: every
"assistant" there is the model's message role, not our product concept.

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

* fix(compaction): re-read compaction state per turn — two managers, one row

Same root cause as #741, different blast radius. The agent cache keys on
configuration, so an `@`-mention turn builds a second `Agent`, and each `Agent`
builds its own `TurnBasedSessionManager`. Both write the same DynamoDB session
row and neither knows the other exists.

`update_after_turn` loaded persisted state only when `initialize()` had skipped
the load. `initialize()` never re-runs on a cache *hit* — the same property that
forked the conversation in #741 — so a hit turn kept whatever state its instance
was built with and then saved it straight over the newer checkpoint and
truncation anchor a sibling had just written. Every exit path of
`update_after_turn` saves, including the "nothing to do" ones, so the clobber did
not need compaction to actually fire on that turn.

This is a cost bug before a correctness one. The truncation anchor is what keeps
the restored prefix byte-stable; moving it backwards re-truncates messages
previously sent whole, which rewrites a 35k-150k-token prefix at the $2.50/MTok
write premium on a turn where nothing about the conversation appeared to change.

Fix is direction (2) from the issue: re-read on every turn. Not the shared-object
approach #750 used for the message list — a sibling may live in another replica,
where aliasing reaches nothing. The read is one GSI query and
`_save_compaction_state` already does an identical one immediately after, so it
roughly doubles a cost that is already noise next to a model call. The
single-flight session lease serializes turns per session, so read-modify-write is
safe. Conditional writes (direction 3) would only re-cover what the lease covers.

State never moves backwards. A load failure is indistinguishable from "nothing
persisted" — both return a default `CompactionState` — so adopting the result
blindly would let one transient DynamoDB error zero a real checkpoint, which is
the clobber this exists to prevent. Adopt only when the persisted record is at
least as far along, and carry the anchor forward even at an equal checkpoint,
since the anchor also advances on prompt-cache expiry.

Removed `_compaction_state_loaded`: nothing branches on it now, and its comment
described a lazy-load that no longer exists. Its one real job — stopping the
failed-compaction path from persisting defaults over a live checkpoint — is now
done by the monotonic guard.

The integration-style test the issue asked for drives two managers against one
shared fake row and asserts the checkpoint never regresses; it fails with
`assert 0 == 8` against the old code, which is the bug verbatim.

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

* fix(agents): retire the unreachable `limits` runnability state

D6 specified three runnability states — ready, limits (degraded), blocked —
and the middle one could never occur. It degraded only when a binding declared
`config.optional == true`; that key was read in exactly one place and written
nowhere. No API accepted it and the Designer had no control for it, so every
gap already resolved to `blocked` and the model was two states pretending to be
three.

The deciding reason is not that it was dormant, though. Building toward it
would have contradicted `agent-designer.md` D5, whose Non-goals say "No
**downgrade** on missing capability (block-only v1)" and call downgrade "a later
opt-in". Block-only is what `agent_binding_resolver` actually implements — it
raises for model, tool, skill and memory_space alike, and `optional` appears
nowhere in it. Making the preview offer a third outcome would have required
teaching the resolver to skip bindings, which is a real product decision about
whether an Agent may silently run degraded, not a checkbox. Two specs disagreed;
this settles them on the one that shipped.

So: `RunnabilityState` is `ready | blocked`, `MissingCapability.optional` and
`_is_optional` are gone, and the detail page and admin default-pins page stop
rendering a distinction they could never draw.

The `optional` flag is not merely unused now — it is inert, and a test pins
that. A hand-edited or future record could still carry it, and it must not
resurrect a state the runtime cannot honour.

D6 is revised rather than quietly trimmed: it records that there were three
states, why the middle one went, and that if downgrade is ever taken up it
starts at the resolver, not at the preview layer.

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

* feat(marketplace): make locked seeds cost something to an admin, not to a cap

Resolves the D9 "locked-pin ceiling" open question. No cap; friction.

Phase 6 sharpened the concern the question was written about. A locked seed
genuinely cannot be dismissed — verified on dev, a lock beats a user's own
tombstone, which is right, because a lock a user could dismiss would be
pointless. So an admin choosing between "seed" and "seed locked" has no reason
not to lock: locking guarantees the rollout lands and the cost falls on someone
else's sidebar. Left alone, the dominant strategy is to lock everything and
Pinned stops being the user's shelf.

A cap does not work here, and the reason is structural rather than a matter of
picking the right number. Pins merge as a union across every role a user matches
and a lock from any one of them wins (D9.2), so a per-role cap of two does not
mean a member sees two locked Agents — someone in five roles sees ten. Capping
the union instead is not implementable: role membership resolves per user from
Entra claims at request time, so which roles co-occur on one person is unknowable
when an admin saves a seed list. Enforcing at read time would be worse than no
cap, silently dropping an admin's lock for some users — a rollout that looks like
it landed and did not.

So the console shows what it can actually know. A running locked count on the
seed header, a warning past a threshold, and `lockedElsewhere` — how many locked
seeds every *other* role holds, which is the one fact an admin cannot work out
from their own page and the only honest way to show the union. Advisory
throughout: `count_locked_outside` swallows a failed role listing rather than
taking the pins page down with it.

No new failure mode, nothing to migrate for roles already over any line, and a
threshold that moves without breaking a saved list. If over-locking shows up,
revisit with the counts this makes visible.

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

* docs(agents): settle the Agent specs, and make the tagline decision true

Closes the spec-hygiene issue. Four items, and the last one needed code.

**Phasing table** — already fixed while shipping #746; verified, no change.

**Quota attribution** — resolved, in code rather than by inference. The invoker
pays: `check_quota(user=current_user, …)` at chat/routes.py:1319 with no
author-side lookup anywhere on the invocation path, and cost rows land on the
same person (`PK = USER#{user_id}`, metadata.py:157). Both file:lines are in the
spec so nobody re-derives it.

**Review SLA** — committed and split by queue: two business days for
submissions, same day for an `inappropriate` report, weekly for the rest. Phase
8 added reports as a second queue and they are not the same clock — a submission
is a person waiting to publish, a report is mostly maintenance signal. D2 had
been asserting "two business days" in its body while Open Questions said the
commitment was never made; that disagreement is gone.

**`tagline` backfill** — derive and let the author edit, at submission. Writing
that down alone would have re-opened the gap this issue exists to close, because
implementing it turned up a second problem the question never mentioned: tagline
was *author-owned on the wire and unsettable in the UI*. The API accepted it and
the model comment said the author owns it, but no Designer control ever wrote
one — the same dormant-field shape as the `limits` state in #747. So submission
is now where it gets set, prefilled by `deriveTagline` from the description's
first clause. The derivation is not the clever part; putting the shelf row in
front of the author at the one moment they are looking at what the store will
say is. A bad line is then one edit away instead of a surprise after publication.

Also fixed the same trap on the specs' own front pages: both still read
"Status: Draft / proposal" while describing shipped code. That is precisely the
`agent-directory.md` failure this issue cites — a document whose face does not
say what it is. Both now state what is implemented and point at the revisions
recorded in place (D6, D9.7, D14).

Open questions on the marketplace spec are now empty.

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

* feat(costs): tell a deliberate @-mention swap from a cache regression

An `@`-mention hands one turn to a different Agent (Marketplace D11), which
swaps the system prompt and toolConfig and so genuinely re-writes the prompt
cache. That classifies as `miss_avoidable`, correctly — the tokens really were
spent at the write premium. The problem was that nothing on the `C#` row said
the turn was a mention, so a deliberate swap looked exactly like the
nondeterministic-ordering regression the fingerprints exist to catch: both
present as `toolConfigHash` and `systemPromptHash` flipping together. Expected
traffic was diluting the one signal that should never move.

A dimension, not a reclassification. The row records `turnAgentId` and, where
it differs from the previous call's, `agentSwitched`. `cacheStatus` and
`wastedUsd` are untouched, and the session rollup *splits* rather than deducts:
`agentSwitchMissCount` / `agentSwitchUsd` are a subset of `avoidableMissCount` /
`wastedUsd`. Hiding the spend would understate what mentions cost, which is a
thing worth being able to measure on purpose; subtracting gives unexplained
waste, which is the number a regression moves.

Three notes on how rather than what:

The agent id is threaded per turn from the route rather than read off the agent
object. The agent is cached and shared across turns, so per-turn state must
never live on it — that is exactly what forked history in #741 and compaction
state in #751.

`agentSwitched` is derived at write time because `_derive_cache_observability`
is the only place already holding the predecessor row. It compares against the
*previous call*, not the same-prefix match the TTL clock uses: the question is
whether the Agent changed from one turn to the next, and a same-prefix row is by
construction one where it did not.

EMF gains `AgentSwitchMiss` as its own metric rather than a dimension on
`AvoidableMiss`. The namespace deliberately has no dimensions — fleet-wide sums
are the alarm target — so a dimension would multiply metric streams per Agent.
`turnAgentId` rides as a property, queryable without that cost.

The admin anatomy page keeps the red count whole and names the explained part
underneath, so the number an admin reads as alarming only ever means
unexplained. Older rows carry neither attribute and read as unswitched; there is
no backfill and none is needed.

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

* feat(agents): replace the assistant card with an agent launch card

The card that greeted you when an Agent opened was the last pre-Marketplace
surface, and it was out of continuity on every axis that matters.

The one that matters most: it hashed the **first letter of the name** into its
own 26-entry pastel palette, while every other surface hashes `agentId` through
FNV-1a into the twelve curated gradients. The same Agent was therefore drawn two
different ways either side of a tap — exactly what the note on `hashAgentId`
exists to prevent. Two of those 26 (`J`, `P`) were also near-white under white
text, an AA failure rather than a taste call.

The replacement is the store's detail page folded to chat width, in the same
reading order the user just tapped through: tile, name, tagline, publisher, what
it can reach, what to ask. Starters stay buttons — unlike the detail page's
read-only list, the chat has real composer prefill behind `starterSelected`.

- Tile is `app-agent-icon` at 52px, so the store, My Agents, the chat header and
  this card all draw the same artwork.
- Reads the `Agent` shape, not `Assistant`: tagline, publisher, category and
  capabilities exist only there. No new fetch — `session.page` already loaded it
  to lock the chat-input pickers. Falls back to the `Assistant` shape so the card
  still paints before the Agent resolves, or with `/agents` disabled.
- D6's run line, from a best-effort `getRunnability` after the Agent load, and
  advisory exactly as it is on the detail page.
- Add/Added and the detail link are gated on a published listing. A private agent
  has no detail page and nothing to add.
- The close control moved into the card's header row; it used to be an absolutely
  positioned white button floating over a gradient banner that no longer exists.

`models/runnability.ts` gives the D6 sentence one source — the detail page reads
from it too, so the two surfaces can't drift into two phrasings of one state.

`assistants/components/assistant-card.component.ts` is deleted; no consumers
remain and the folder's README consumer map is updated. `assistants/` itself
stays — the share dialog, KB dialogs and services still live there.

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

* feat(agents): make Discover read like a storefront

Discover was an h1, a search field, then category headings over a two-column
list of 40px text rows — the information architecture of a store with the layout
of a directory. Featured, which the code itself calls the marketplace's only
ranking lever, rendered as a small card in a row of small cards.

- **Spotlight** (`app-agent-spotlight`): `featured[0]` gets the front door, with
  84px artwork, Start chat and Add. The band is tinted by the agent's own tile
  gradient via `gradientFor(agentId)`, so the storefront changes character with
  what is on it and can never clash with the artwork sitting in it.
- **Store tile** (`app-agent-store-tile`) replaces the shelf row here. Artwork to
  52px and the `+` is **always visible** — the row had it at `opacity-0
  group-hover:opacity-100`, which on a touch device means the store's primary
  verb does not exist. Still no model chip, no counts, no runnability badge (D4).
- **Category chips** filter the loaded shelves; tapping the active chip clears it,
  so there is no separate reset. Hidden while searching — search already crosses
  every category, and two filters would offer two answers to one question.
- **Your agents** becomes a rail of tiles rather than name pills.

Two judgement calls worth reviewing:

**The scrim over the spotlight gradient is load-bearing, not decoration.** The
twelve palette entries were tuned to keep a white-ish *emoji* legible at tile
size; two of them (amber→orange, lime→green) put white *body text* under 4.5:1.
A fixed scrim makes the contrast a property of the component rather than of which
agent an admin happened to feature, so no future palette entry can silently break
the band. There is a spec asserting it is always present.

**Shelves stayed a 3-up grid, deliberately not a horizontally-scrolling rail.**
The rail is the app-store shape and it is wrong at this corpus size: one holding
three items reads as a broken carousel, and with `GSI5_SK` on `created_at` there
is no ranking to justify hiding items off-screen. "Your agents" *is* a rail
because that list is genuinely unbounded and ordered by the person reading it.

`featured.slice(1)` renders as an "Also featured" shelf rather than folding into
the categories: shelves page at 12, so an older featured agent could fall off the
shelf it belongs to and vanish silently.

`app-agent-listing-row` is untouched and still serves the Pinned tab.

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

* feat(agents): add a list view to My Agents

A grid/list toggle in the page header, remembered per device via
`LocalSettingsService`. Grid stays the default: it is the view that shows an
agent's artwork at a size you recognise, and most people have few enough agents
that density is not yet the problem.

Both views render the same agents with the same controls. The toggle changes
density, never what is available — a view that quietly dropped the publication
controls would make "which layout am I in?" a question an author has to answer
before they can find out why their submission came back.

That constraint is why `app-listing-status` gained a `part` input. The list row
needs the state badge inline beside the name and the reviewer's note on its own
line below, which the stacked card treatment cannot give it. It is a **split, not
a filter**: no caller may render `'badge'` and drop the note. `'all'` remains the
default, so the grid card is unchanged.

Rows are one line each until an agent has something to say — a review note, or
the marketplace lifecycle controls — and those rows earn their extra height.

The toggle is a `radiogroup` rather than two independent toggle buttons: it is
one setting with two values, and a screen reader should hear it that way. It is
hidden entirely when there are no agents, so it never sits above an empty state.

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

* docs(admin): spec delegated admin scopes

Admin access is a single bit today: require_admin is
require_app_roles("system_admin"), guarding 112 handlers across 15 admin
router packages, with the SPA gating only the /admin parent route.

Proposes a fourth AppRole grant axis (grantedAdminScopes) scoped 1:1 with
the admin router packages, led by the escalation analysis:

- admin.roles and admin.auth_providers are permanently non-delegable —
  IdP claim mapping controls which AppRoles resolve, so delegating it is
  role administration by another route.
- Scopes live on the AppRole so the only surface that can write them is
  the non-delegable roles admin.
- Closed code-defined registry, never grantedTools — the roles UI has no
  free-text entry, which is what made the skills and scheduled-runs
  capability gates inoperable.
- No wildcard, no inheritance, and no write-through to a protected or
  scope-bearing role from a resource surface.

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

* docs(admin): resolve open questions on delegated admin scopes

- Audit logging is in scope, as PR-5. Notes that admin_service already
  emits structured records on every mutation, so PR-5 promotes existing
  emission points rather than adding a new instrumentation pass.
- The ~5-minute scope-revocation lag is accepted; it matches the window
  that already applies to removing system_admin.
- No read/write split in v1.
- No example delegated role in the seeder.

Also moves grantedAdminScopes on the role create/update bodies from PR-3
into PR-1, so the axis round-trips end to end in one change.

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

* feat(rbac): add delegated admin scopes as an AppRole grant axis

PR-1 of docs/specs/granular-admin-permissions.md. Data path only — no
authorization check reads admin scopes yet, so behavior is unchanged.

Adds `granted_admin_scopes` to AppRole alongside the existing tools,
models, and skills axes, resolved through EffectivePermissions and
UserEffectivePermissions into the per-user merge.

The registry in rbac/admin_scopes.py is closed and code-defined rather
than catalog-derived. The roles admin UI builds its grant controls from
resource catalogs with no free-text entry, so a capability id that is
not a catalog entry cannot be granted from the UI at all — that is what
made the earlier skills and scheduled-runs capability gates inoperable.

Escalation guards:
- admin.roles and admin.auth_providers are non-delegable. Editing a role
  grants arbitrary permissions; editing IdP claim mapping decides which
  roles resolve at all. validate_admin_scopes rejects both, at the
  service layer, so the rule holds for the REST API and scripts alike.
- No wildcard on this axis. Full admin stays spelled system_admin, and a
  stray "*" resolves to an unknown scope that matches nothing.
- Scopes do not inherit. A child role picks up a parent's tools but none
  of its admin power.
- system_admin cannot carry scopes; update_role's existing protected
  field stripping already covers it, now asserted.

Persisted as a plain attribute on the DEFINITION item rather than as
mapping items with a GSI: nothing needs the reverse lookup, and every
extra prefix is another case _delete_mapping_items has to know about.

Also replaces a SimpleNamespace stand-in for EffectivePermissions in the
prompt-cache determinism test with the real dataclass — a hand-rolled
stub silently loses any field added to the type it imitates.

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

* fix(inference): restore chat — stream_response never accepted turn_agent_id

Every chat turn on dev returned AgentCore 424. The container was 500ing before
the model was ever reached:

  TypeError: StreamCoordinator.stream_response() got an unexpected keyword
  argument 'turn_agent_id'

#756 (d64d4208) threaded `turn_agent_id` through `_store_message_metadata`,
`ChatAgent.stream_async`, `base_agent` and `voice_agent`, and forwarded it from
inside `stream_response`'s own body — but never added it to `stream_response`'s
signature. `ChatAgent` passes the kwarg on *every* turn, not only mention turns,
so this was a total outage rather than a mention-path bug: verified on dev with
an Agent attached and with zero tools and no Agent, same TypeError both times.

One line to fix; the body already referenced the name.

The interesting part is why CI was green. The existing coordinator stubs take a
bare `**kwargs`, so they accept arguments the real coordinator rejects — a stub
shaped like the *caller* cannot fail on caller/callee drift. The new tests bind
against `inspect.signature(StreamCoordinator.stream_response)` instead, which
reproduces the production TypeError in-process. All four fail against the
unfixed coordinator.

The last of them asserts that *no* kwarg `ChatAgent` forwards is unknown to the
coordinator, so the next parameter added to this seam is covered without anyone
remembering to write a case for it.

Fixes #756 regression
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(timestamps): stop emitting `+00:00Z`, which no browser can parse

`datetime.now(timezone.utc)` is tz-aware, so `.isoformat()` already renders the
offset as `+00:00`. Appending `"Z"` produced

    2026-07-27T05:09:55.853557+00:00Z

— an offset *and* a Z — which is not valid ISO 8601. `new Date()` returns
`Invalid Date` for it, and every SPA formatter falls back silently, so the bug
looked like missing data rather than a bug:

  * the agent detail page showed "Last updated —" on an agent edited minutes ago
  * admin Reports showed "recently" for every report ever filed

Found while smoke-testing the Agent epic on dev. It is NOT an epic regression —
it is long-standing, and the epic's new date-rendering surfaces are simply the
first place anyone read one of these values. Three call sites had already
discovered it independently and written local workarounds (`sync_policies`,
`users/sync`, `users/repository`), which is the clearest sign it needed one
shared implementation rather than a convention to remember.

`apis.shared.timestamps` is that implementation, promoted from the existing
`sync_policies._iso`. 52 writer sites now call `utc_now_iso()` / `to_iso()`; the
two duplicate `_iso` definitions collapse onto it.

**The part that would have bitten quietly.** Readers used

    datetime.fromisoformat(value.rstrip("Z"))

which only worked *because* the writer was broken: stripping `Z` off `…+00:00Z`
leaves a valid offset (aware), but stripping it off a correct `…Z` leaves a bare
naive datetime. Fixing the writers alone silently flipped round-tripped values
from aware to naive — `test_skills_models.py::test_round_trip_preserves_fields`
caught it, and any later comparison against `datetime.now(timezone.utc)` would
have raised `TypeError`. All 11 such readers now use `from_iso`.

**No backfill.** Rows already written keep the old spelling — `createdAt` is
never rewritten — so the SPA normalizes on read instead (`parseIso` in
`utils/date.ts`, applied at 30 parse sites). That fixes historical rows too,
without rewriting values embedded in GSI sort keys (`GSI5_SK =
CREATED#{created_at}`). Mixed spellings compare safely: the suffix differs only
*after* the full date-time-microseconds, so distinct instants still order
correctly.

Two tests grep the tree so neither idiom can come back — verified failing by
reintroducing each.

Verification: ruff at baseline (164, unchanged); backend 5268 passed, 3 skipped;
SPA 147 files / 1644 tests passed.

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

* feat(rbac): enforce delegated admin scopes on admin routes

PR-2 of docs/specs/granular-admin-permissions.md. Makes the scopes added
in PR-1 actually govern access. Behavior for system_admin is unchanged:
the superuser satisfies every scope implicitly.

Adds `require_admin_scope(scope)` and migrates 13 admin router packages
to it, one scope per package, so the permission boundary is the package
boundary. Each package names its dependency after its area
(`require_tools_admin`, …), mirroring the pre-existing
`require_marketplace_admin`, so tests have a stable public handle.

`roles/` and `auth_providers/` keep bare `require_admin` and now carry
module docstrings explaining why they can never be delegated — the
second is the non-obvious one: whoever controls IdP claim mapping
controls which AppRoles resolve at all.

Write-through guard (spec I3): the tool/model/skill role pickers write
into role records, all landing in `AppRoleAdminService.update_role`. If
the target role is protected or carries admin scopes, the actor must
hold system_admin. The check lives in `update_role` rather than the
three callers so a future resource surface with a role picker inherits
it. Raises `RoleMutationForbidden` -> 403 via a new app-level handler,
not ValueError -> 400, which would misreport a denied escalation as a
bad request.

`_assert_actor_may_mutate` resolves permissions through a service built
from this instance's own repository and cache rather than the global
singleton, which would otherwise bypass an injected repository and issue
real DynamoDB calls from unit tests.

tests/architecture/test_admin_scope_coverage.py walks the mounted admin
router and fails if any route lacks an authorization dependency, if a
scoped package reverts to bare require_admin, or if a registry scope
governs nothing. Both failure modes were verified to actually fail.

Test helper `override_admin_auth` replaces per-test
`dependency_overrides[require_admin]` across 16 files. It reads the
dependencies off the app rather than importing them, because
test_skills_feature_flag.py reloads the admin routes module and rebuilds
those objects — an import-based list silently stops matching and every
request 401s. It deliberately does not override
`require_marketplace_admin`, which also enforces the marketplace kill
switch.

Also rewrites two stale docs that describe an RBAC API that never
existed (`require_roles`, `require_faculty`, "Admin or SuperAdmin") and
five endpoints absent from this module.

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

* feat(marketplace): tell the reviewer when a listed Agent is unopenable

Publication and access are separate axes (D3) and the store only guards one:
`GET /agents/store` is a pure sparse-GSI5 read with no access check, while
`GET /agents/{id}` enforces `get_assistant_with_access_check`. Nothing in the
listing lifecycle touches `visibility`, so an approved PRIVATE or SHARED Agent
gets a shelf tile that everyone can see and only its author can open.

Found on dev, where *both* live listings were in that state — one PRIVATE, one
SHARED — and one of them was a `status=DRAFT` record still named "Untitled
Agent" sitting on the Teaching shelf. The admin copy half-knew ("members can
only open one they could reach on their own"), but every guard was on listing
*state*, never on visibility, and neither the author at submit nor the reviewer
at approve was told.

`reachability` projects `visibility` onto "who can open this" and rides the two
surfaces where someone can act on it: the review queue and the author's submit
preflight. Derived on every read, never stored — `visibility` can change at any
time and a cached copy would be wrong exactly when it mattered.

The two audiences get different words from one shared helper, so they cannot
drift: the author is told how to fix it ("set Visibility to Public"), the
reviewer is not — telling a reviewer to widen someone else's access is the
`allowedAppRoles` trap wearing a different hat.

Advisory throughout. Approve is never disabled, and a test asserts that.

⚠️ Three alternatives were considered and rejected; D3.1 records why so they are
not re-proposed. Blocking submission forbids the legitimate SHARED-to-a-team
publication. Filtering at browse turns the deliberately-pure GSI5 read into N
access checks per page view. Auto-setting PUBLIC on approve is D3's own
prohibition re-entering through the back door.

Verification: ruff at baseline (164); backend 5259 passed, 3 skipped; SPA 148
files / 1646 tests passed. Both new SPA specs verified non-vacuous — neutering
the helper fails 8 of them.

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

* feat(rbac): expose admin scopes over the API

PR-3 of docs/specs/granular-admin-permissions.md. Serves the scope
registry and the caller's own scopes, so PR-4 has something to build the
SPA against. No behavior change: nothing consumes these yet.

- GET /admin/roles/admin-scopes returns the closed registry that feeds
  the role form's scope picker. Non-delegable scopes are included with
  `delegable: false` rather than filtered out, so the picker can show
  them as unavailable instead of leaving an admin wondering why two
  areas are missing. Lives on the roles router, which is non-delegable
  — granting scopes is a system_admin-only act.

- /users/me/permissions gains `adminScopes`, and `skills`, which
  UserEffectivePermissions has carried for months without it ever
  reaching the response — the field-added-to-model,
  forgotten-in-the-response-shape bug the spec flagged. Both default to
  [] so an older client is unaffected.

The registry handler must be declared above `/{role_id}`: FastAPI
matches in declaration order and a single-segment literal loses to a
single-segment path parameter declared first. The symptom is a 404, not
an import-time error, so a declaration-order test guards it — verified
to fail when the handler is moved.

Frontend `UserPermissions` gains both fields to keep the cross-package
contract honest. The guard and nav still gate on system_admin; wiring
them up is PR-4.

Also corrects spec §6.2, which described the dependency as `_require`
across 15 files — it shipped as `require_<area>_admin` across 13 — and
records what PR-2 and PR-3 added beyond the plan.

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

* feat(gateway): migrate inbound auth to Cognito JWT (CUSTOM_JWT)

AgentCore outbound on-behalf-of token exchange can only exchange a *user*
subject token, and the Gateway's IAM/SigV4 inbound auth never carried one.
Switch the Gateway's inbound authorizer to CUSTOM_JWT trusting the platform
Cognito user pool, so the agent's per-user access token reaches the Gateway
and becomes exchangeable for a target-specific token downstream.

One Gateway remains the endstate. AgentCore permits exactly one inbound
authorizer per Gateway (`authorizerType` is a scalar), but outbound
credentials are per-target — so this single Gateway still fronts both
IAM-invoked Lambda targets (arxiv, policy-search) and future
OAuth/token-exchange targets. Splitting Gateways by backend type would
split along the wrong seam.

- `config.gateway.inboundAuth` ('jwt' default | 'iam') with env/context
  override and synth-time enum validation. 'iam' is a code-free rollback:
  both AuthorizerType and AuthorizerConfiguration are CloudFormation
  "no interruption" updates, so flipping does not replace the Gateway or
  orphan its registered targets (confirmed via cdk diff change set).
- Cognito user pool + BFF app client passed as construct refs, not SSM:
  the pool is a sibling in this stack and CFN resolves SSM template
  parameters before any stack resource exists.
- Authorizer validates `allowedClients`, NOT `allowedAudience` — Cognito
  *access* tokens carry `client_id` and no `aud` claim, so an audience
  check can never match and would 401 every call.
- Construct throws at synth when 'jwt' is selected without Cognito refs,
  rather than deploying a Gateway that rejects everything.
- AGENTCORE_GATEWAY_INBOUND_AUTH threaded to the inference runtime from the
  same config value, so agent data-plane auth and the deployed authorizer
  cannot drift.

Tests: 5 new Gateway construct tests (JWT default, client_id-not-audience,
discovery URL, iam rollback, throw-without-refs). New `mockCognitoRefs`
helper uses `from*` imports so it adds zero resources and existing
resourceCountIs assertions stay meaningful. 480/480 jest, tsc clean.

Refs docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md (Phase 1A)

* feat(gateway): send the signed-in user's token to the JWT Gateway

Pairs with the CUSTOM_JWT inbound authorizer: the agent now presents the
signed-in user's Cognito access token as a Bearer credential instead of
SigV4-signing Gateway calls with the task's IAM identity. `self.auth_token`
already held that token, so this threads it through rather than adding a
new credential source.

Both halves must deploy together — once the authorizer flips, SigV4 is
rejected, and until it flips a bearer token is.

- `_build_gateway_auth()` selects bearer vs SigV4 from
  AGENTCORE_GATEWAY_INBOUND_AUTH, which CDK sets from the same
  `config.gateway.inboundAuth` that builds the authorizer — so the two
  cannot drift. Unrecognized values fall back to 'jwt' with a warning
  rather than silently dropping user auth.
- Reuses the existing OAuthBearerAuth from integrations/oauth_auth.py
  instead of adding a second bearer implementation.
- Tokens are bound per client instance, never module-level, so one user's
  credential cannot leak into another user's Gateway client.
- Missing token in jwt mode raises GatewayAuthError; GatewayIntegration
  catches it and degrades to no Gateway tools rather than failing the turn
  (every call would 401 anyway). Error text points at the headless-grant
  path, the likely cause for a scheduled run.

Verified no non-user caller loses access: scheduled/headless runs already
mint a real Cognito access token via CognitoRefreshBearerAuth (and
run_agent_headless requires it to start today), and the API-key path calls
Bedrock Converse directly without touching the Gateway.

Tests: 11 new — auth-mode resolution, the actual Authorization header
value, missing/empty token rejection, iam rollback ignoring the token,
cross-user token isolation, and graceful degradation. Full backend suite
5419 passed.

Refs docs/specs/AGENTCORE_GATEWAY_TOKEN_EXCHANGE_PLAN.md (Phase 1B)

* docs(gateway): warn forks about SigV4 callers before the JWT switch

The Gateway inbound-auth default flips to `jwt`, which means the Gateway
stops accepting SigV4. In this repo the agent is the only data-plane caller,
so the migration is self-contained — but a fork that added its own Lambda,
scheduled job, or service calling the Gateway with SigV4 would start getting
401s with nothing in the code to warn them.

Documents `CDK_GATEWAY_INBOUND_AUTH` in the per-environment overrides table
and adds a "Gateway inbound authentication" section covering the upgrade
check, the `iam` escape hatch, and the two things that are *not* affected
(registered targets keep working; the authorizer swap never replaces the
Gateway). Also notes the infra+backend deploy-together requirement.

Docs-only. Astro build clean (51 pages).

* fix(gateway): default inbound auth to iam — the authorizer is immutable

PlatformStack failed deploying #778 to dev:

    Authorizer type cannot be updated for an existing gateway
    (Service: BedrockAgentCoreControl, Status Code: 400)

AgentCore will not change a Gateway's authorizerType after creation. The stack
rolled back cleanly and the Gateway kept AWS_IAM, READY, and both targets — but
the migration as designed cannot work in place.

Neither pre-deploy check caught this. The CloudFormation resource reference
documents AuthorizerType as "Update requires: No interruption", and `cdk diff`
via a real change set reported an in-place [~] modify. Both describe CFN's
plan, not the AgentCore service's validation. A change set is not a deploy test.

Two failures to fix, not one:

1. The backend half shipped while the infra half rolled back, so the runtime
   had no AGENTCORE_GATEWAY_INBOUND_AUTH — and the agent's default was 'jwt'.
   That pointed the new agent at bearer auth against an AWS_IAM Gateway, 401ing
   every Gateway tool call. Both defaults are now 'iam': an absent value means
   "behave like the Gateway that is actually deployed", which is the only safe
   direction when the two halves can land independently.

2. CDK_GATEWAY_INBOUND_AUTH existed only in config.ts — step 1 of the repo's
   7-step config pattern. The documented escape hatch was unreachable from CI.
   Now exported and validated in load-env.sh, passed as context (synth.sh and
   deploy.sh already use build_cdk_context_params), and carried in platform.yml's
   job env. backend.yml runs no CDK, so it needs nothing.

Also corrects every place that asserted the opposite: the GatewayConfig doc, the
construct's inline comment and class doc, the plan's Phase 0/1A, and the public
environments.md page (now a :::danger: covering the real error and why the
pre-deploy signals mislead).

The single-Gateway endstate still holds. What changes is the mechanism: reaching
CUSTOM_JWT needs a new Gateway plus target re-registration and a cutover, not a
config flip. Targets are managed out-of-band by app-api's GatewayTargetService
and the mcp-servers repo, so that needs its own design — tracked in the plan.

Verified: synth against dev now emits AuthorizerType AWS_IAM matching the live
Gateway, and `cdk diff` shows no authorizer change, so the deploy proceeds.
tsc clean; infra 481 jest; 58 backend gateway + supply-chain tests.

* feat(rbac): gate the admin console on delegated scopes

PR-4 of docs/specs/granular-admin-permissions.md, the last one. Makes
the scopes reachable through the UI: a delegated admin can now be
granted areas from the role form, enter the console, and see only what
they hold. Full admins see no change — `hasAdminScope` short-circuits
on system_admin, so the console is identical to before.

- UserService gains `adminScopes`, `hasAdminScope()`, and
  `canAccessAdmin`. `isAdmin` keeps its exact previous meaning and still
  gates genuinely superuser-only surfaces.
- `adminGuard` gates the /admin shell on `canAccessAdmin` rather than
  `isAdmin`; a new `adminScopeGuard` gates each page on its
  `data.scope`. A route with no scope is DENIED, not allowed — failing
  open there would hand every delegated admin an unvetted surface.
- The /admin landing was a static `redirectTo: 'costs'`, which would
  drop a skills-only admin on a page they cannot open. It is now a guard
  that resolves the first area the user can actually reach.
- Nav filters by scope and drops emptied groups. The marketplace badge
  fetch is gated on `admin.marketplace`; unconditional, it was a
  guaranteed 403 on every navigation for admins without it.
- The role form grows an Admin Access picker, grouped by the same
  headings as the admin nav and fed by GET /admin/roles/admin-scopes.
  Non-delegable areas render disabled with a lock rather than being
  omitted, so it is visible that roles and auth providers are withheld
  by design rather than missing by accident.

`admin-scope-wiring.spec.ts` is the SPA counterpart to the backend's
architecture test: it fails if an admin route lacks a scope or guard, if
a scope is not in the registry, or if a nav entry's scope disagrees with
its route's. Verified to fail on an unscoped route (3 tests catch it).

The sidenav specs stubbed UserService with `isAdmin` only, so moving the
admin entry point to `canAccessAdmin` broke five of them — stubs updated
rather than the source reverted.

SPA: 1675 tests pass, AOT build clean.

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

* fix(marketplace): require PUBLIC visibility to publish an agent

The store gates browse on `listing.state` alone, while pinning gates on
`visibility`. An agent could therefore be published while still SHARED or
PRIVATE: a tile everyone saw and only the author could open. Two users hit
this during the dev demo — `POST /agents/{id}/pin` returned a bare
"Agent not found" for a tile the store had just offered them.

The marketplace is public-only. Sharing an agent with named coworkers is a
separate mechanism, and a listing carries no audience of its own, so a
published non-PUBLIC agent is incoherent state rather than a team listing.

- Block submission unless the agent is PUBLIC, surfaced by `preflight_listing`
  (the dialog already renders a block reason and hides the form) and enforced
  by `submit_listing`. A refusal, not a silent widening: publication must not
  be a side door that changes who can reach an agent.
- Re-check at approval. `visibility` can be narrowed between submitting and
  being reviewed, so the submit-time gate says nothing about approval time.
- Answer a pin denial on an already-published agent with a legible 403 instead
  of collapsing to 404. The store advertised that id, so its existence is not
  a secret; the collapse still applies to everything else, and the extra
  lookup is best-effort so it can never escalate a 404 into a 500.

`_reachability` stays: an agent published as PUBLIC and narrowed afterwards is
the case no gate can catch. Its comment — and the SPA's — claimed publishing a
SHARED agent to a team was legitimate, which is what made this look intended.

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

* feat(marketplace): let authors go public from the submit dialog

Requiring PUBLIC to publish left the *common* path a dead end. Every agent is
created PRIVATE, so a first-time author opened Submit to a red block telling
them to go set visibility on the agent editor and come back — two screens for
one decision, and worse than the amber warning it replaced.

Consent now lives where the decision is made. The submit dialog shows a
checkbox ("Make this agent public"), `makePublic` rides the submit request, and
`write_listing` widens visibility in the same write as the listing. One write
matters: two could leave an agent listed but unreachable, which is the exact
state this whole gate exists to prevent.

It stays consent rather than a side door. The box starts unticked, Submit is
disabled until it is ticked, and the flag defaults to false — so a direct API
caller who omits it is refused exactly as before, and an already-public agent
never has its visibility rewritten.

`blockReason` and `requiresPublic` are now separate signals. A block means
"leave the dialog and fix someth…
@philmerrell
philmerrell merged commit 4bf2baf into develop Aug 17, 2026
4 checks passed
@philmerrell
philmerrell deleted the backmerge/main-into-develop-1.15.0 branch August 17, 2026 20:25
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