Skip to content

Release/1.16.0 - #896

Merged
philmerrell merged 2073 commits into
mainfrom
release/1.16.0
Aug 28, 2026
Merged

Release/1.16.0#896
philmerrell merged 2073 commits into
mainfrom
release/1.16.0

Conversation

@philmerrell

@philmerrell philmerrell commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Minor release on knowledge bases, marketplace governance, and fine-tuning.

Full detail: RELEASE_NOTES.md · CHANGELOG.md

Pre-merge checks

  • GSI update-limit: PASS. One index — KbWorkIndex on the existing rag-assistants table. node scripts/release/check-gsi-update-limit.mjs compared 26 tables against origin/main; no existing table needs more than one GSI operation.
  • Version sync: PASS. VERSION 1.15.0 → 1.16.0, all manifests and lockfiles regenerated.

What's in it

Verified against the live environment

All three managed-KB flags are already explicitly false on the production environment (set 2026-08-27), so everything managed-KB is inert on merge: no provisioning, no dispatcher, no upgrade card. The reconciler's daily rule is created enabled but is report-only — it refuses to arm from an invocation event, only from MANAGED_KB_RECONCILER_ARMED — and it enumerates Bedrock managed KBs by tag, so it cannot touch the S3 Vectors corpus.

Existing knowledge bases keep working: load_record returns {} for a KB with no record and both absence and an unreadable read resolve to ENGINE_LEGACY.

Four changes to the live retrieval path are NOT flag-gated. Three are no-ops in the current configuration; one is a real behaviour change:

  • Fail-closed document-status filter — the only changed surface is whole-table failure. The unset-variable branch cannot fire (DYNAMODB_ASSISTANTS_TABLE_NAME is set by both app-api-environment.ts:282 and inference-agentcore-construct.ts:310), and a per-document lookup failure still skips only that document, unchanged. What changes: during a DynamoDB outage on the assistants table, RAG turns now return zero chunks instead of unfiltered ones. Deliberate — 936 retrievals in a trailing 30-day window had chunks dropped by this filter.
  • Query clamp at 10,000 chars — only bites queries over 10k, tail-truncated. The legacy path was previously unclamped to ~32k.
  • Access-grant threading — behaviour-preserving. get_assistant_with_access_check returns exactly owner/editor/viewer/None, and all three non-null values are in KB_READ_PERMISSIONS. Only the marketplace review_preview path passes None deliberately. Both facade callers were updated, and access is keyword-only-required so a missed one would fail loudly rather than silently deny.
  • One new DynamoDB GetItem per retrieval (load_record) — new per-turn cost on the existing path, not previously incurred.

⚠️ Deploy notes

  1. Set CDK_TAG_ENVIRONMENT before running platform.yml. Already setprod on the production environment as of 2026-08-27. Verified, no action needed.
  2. Fine-tuning becomes reachable with this deployCDK_FINE_TUNING_ENABLED is already true and the flag is default-ON regardless. Access stays gated: CDK_FINE_TUNING_DEFAULT_QUOTA_HOURS is 0 = whitelist-only, so a user without an explicit grant gets a 403 and none is auto-provisioned. Verified against the live repo variables.
  3. The RAG document-status filter now fails CLOSED, ungated. Confirm DYNAMODB_ASSISTANTS_TABLE_NAME is set on every retrieval-serving service; watch KbStatusFilterFailClosed.
  4. Retrieval queries clamp at 10,000 chars on both backends, including the previously-unclamped legacy path. Watch KbQueryClamped.
  5. backend.yml has two new jobsbuild-kb-migrationdeploy-kb-migration-code must run once or the four Lambdas stay bootstrap no-ops.
  6. Teardown order changeddestroy.sh runs managed-kb.sh as Phase 0 and aborts the teardown on failure.

Order: CDK_TAG_ENVIRONMENTplatform.ymlbackend.ymlfrontend-deploy.yml.

philmerrell and others added 30 commits July 27, 2026 07:55
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>
…-admin-permissions

docs(admin): spec 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>
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>
…ent_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 (d64d420) 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>
…opes-spec-decisions

docs(admin): resolve open questions on delegated admin scopes
…nse-turn-agent-id

fix(inference): restore chat — stream_response never accepted turn_agent_id
…opes-data

feat(rbac): add delegated admin scopes as an AppRole grant axis
`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>
…stamps

fix(timestamps): stop emitting `+00:00Z`, which no browser can parse
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>
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>
…opes-enforcement

feat(rbac): enforce delegated admin scopes on admin routes
…reachability-warning

feat(marketplace): tell the reviewer when a listed Agent is unopenable
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>
…opes-api

feat(rbac): expose admin scopes over the API
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)
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)
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).
…jwt-inbound-auth

Gateway inbound auth: migrate to Cognito JWT (token-exchange Phase 1)
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.
…und-auth-fail-safe

fix(gateway): default inbound auth to iam — authorizer is immutable after creation
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>
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>
…opes-spa

feat(rbac): gate the admin console on delegated scopes
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 something" and hides the form; needing to go public
is resolvable in place. Collapsing them was what made this a dead end, and the
memory-space block is now the only true dead end left. Ticking the box does not
wave it through.

Drops `reachabilityAuthorMessage`: it told authors to "set Visibility to Public
first", which is now wrong advice. That file is reviewer-facing only — an agent
published as PUBLIC and narrowed afterwards is still the case no gate catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…requires-public-visibility

fix(marketplace): require PUBLIC visibility to publish an agent
An approved listing is not currently a fixed thing. The store reads the
live record via GSI5, and update_assistant/delete_assistant guard on
ownership alone — so an author can rewrite an approved Agent's
instructions or hard-delete it out of the store.

The consequence is an invocation problem rather than a display one: a
user who pinned an approved Agent runs the live instructions, so a
post-approval edit changes behavior for every pinned user with no review.
Today the platform only detects this after the fact, via
approvedInstructionsHash and the ListingDrift marker.

Proposes an immutable snapshot cut at submission (not approval, which
would let the record move between review and publish) and promoted on
approval. The store index moves onto the version item so draft content
has no key in it, and invocation resolves the published version for
everyone but the owner — one seam at chat/routes.py:1469, since
resolve_agent_invocation already takes an Assistant.

Also: withdrawal becomes an admin-approved request, delete is refused
while a listing exists, and drift detection is removed rather than left
dormant.

Records that PublisherProfile (D12) already provides the alternative
author-name feature including the verified check mark — the gap there is
only a missing admin page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/assistants` was a bare `redirectTo: 'agents'`. That answers the routing
question and none of the human one: someone who bookmarked their Assistants
list lands on a page with a different name, a different tab strip and a
different vocabulary, with nothing to tell them their work came with it.

The list URL now renders an explainer. Four sections, in the order the
questions actually arrive:

- Your assistants are safe — same name, instructions, starters, knowledge
  bases, sharing, and old links.
- Why the name had to grow — a ledger table with a spotlight band down the
  Agent column. Instructions / knowledge base / sharing are a yes in both;
  model, tools, skills and memory spaces are a yes only under Agent. That
  table is the argument for the change, not decoration around it.
- What that opens up — store publishing, pinning, `@`-mention mid-chat,
  scheduled runs.

Every claim maps to a surface that ships today, checked against the code
rather than the spec. If one is removed or gated, the claim goes with it —
an explainer that oversells is worse than the redirect it replaced.

The two *deep* links stay silent redirects. `/assistants/:id/edit` is an
intent, not browsing, and interrupting "edit this specific record" with an
announcement would be hostile. Only the URL people browse to explains itself.

Sidenav: a New badge beside Agents, and the Assistants entry back as a muted
signpost onto the explainer. Removing the word from the nav is exactly what
makes someone who built an assistant think theirs is gone. The badge is
primary blue rather than the amber the preview surfaces wore — this is not
unstable, it is unfamiliar, and amber here would say the opposite of what is
true. The signpost sits inside the `showAgents()` gate: with the agent
surface off every call to action on that page is dead, and a nav entry
leading to a page of dead links is worse than no entry.

Both nav changes are marked in-code as transitional — retire them together
once the rename has stopped being news.

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

feat(agents): explain the rename at /assistants instead of redirecting
DerrickF and others added 23 commits August 27, 2026 15:40
Replaces the bootstrap stub with the actual handlers, which is the last thing
standing between an enrolled knowledge base and a completed migration. Until
now PlatformStack shipped four no-op Lambdas and the dispatcher ticked into
them every 15 minutes.

Seven artifacts, not the five the spec anticipated — `deploy-image-lambda-one.sh`
also needed per-function cases:

* backend/Dockerfile.kb-migration
* backend/src/apis/app_api/kb_migration/requirements.txt
* the kb-migration case in scripts/build/build-one.sh
* four cases in scripts/build/deploy-image-lambda-one.sh
* build-kb-migration + deploy-kb-migration-code in backend.yml
* entries in both hand-maintained supply-chain lists

⚠️ THE boto3 PIN IS THE FEATURE, NOT HYGIENE.

The Lambda base image at our pinned digest bundles boto3 1.40.4, whose packaged
bedrock-agent model offers `type` enum ['VECTOR', 'KENDRA', 'SQL'] and has no
`managedKnowledgeBaseConfiguration` shape at all. Measured, not assumed. Without
boto3==1.43.68 installed over it, every CreateKnowledgeBase call fails with a
ParamValidationError naming a parameter that looks correct in our source. Removing
or downgrading the pin breaks the feature silently at runtime and nothing else in
the repo would notice, so three tests now guard it — including one that asserts
the *capability* (MANAGED in the enum, the embedding pin members, FLOAT32
uppercase, all four document operations) rather than just the version string.

The COPY surface is five directives for a 16-module closure with boto3 as the
only dependency. That is the `kb_backend` boundary paying off: its `__init__` is
empty and its module scope is stdlib-only, so this image never pulls
`apis.shared.assistants` and the embeddings stack behind it. No FastAPI, no
pydantic — KB_Record is a dataclass for exactly this reason.

Verified in the built container on linux/arm64: all four handlers import, all
nine kb_backend modules import, boto3 resolves to 1.43.68, MANAGED is in the
enum and all four document operations are present. Also verified by staging the
Dockerfile's COPY list into a bare tree and asserting every closure module
resolves from inside it and nowhere else — module-level imports alone prove
little here, since most of these are function-local and only run on invocation.

Three mutations confirmed caught by named tests: pin removed, pin downgraded to
the base image's 1.40.4, and a COPY dropped.

Tests: 6,772 backend (5 pre-existing Strands SDK failures).
…n-image

feat(kb): ship the real kb-migration Lambda image
No knowledge base could migrate. The first real dispatcher tick after the image
landed raised, and would have raised every 15 minutes forever:

  RuntimeError: KB_MIGRATION_WORKER_FUNCTION_NAME is not set

The construct set `MANAGED_KB_WORKER_FUNCTION_NAME`; `dispatcher.py` reads
`KB_MIGRATION_WORKER_FUNCTION_NAME` — the house convention its siblings use
(`KB_SYNC_WORKER_FUNCTION_NAME`, `SCHEDULED_RUNS_WORKER_FUNCTION_NAME`). The IAM
grant was already correct, so only the name was wrong. kb-sync does not have this
bug for one reason: `kb-sync.test.ts` asserts the variable is present.

A second mismatch found by the same sweep: the construct published
`MANAGED_KB_RETENTION_WINDOW_DAYS`, which nothing reads, while
`worker._retain_days()` reads `KB_MIGRATION_RETAIN_DAYS`. Requirement 15.11's
configured retention window was therefore being silently replaced by the code's
30-day floor. Safe, because the floor is the required minimum — but the operator's
value was going nowhere.

The pre-existing infra test asserted the retention variable under the construct's
own spelling, so it confirmed the construct against itself and passed while the
value was dropped. Corrected rather than annotated.

Fourth wiring mismatch of this shape in this feature — code that reviews cleanly,
deploys cleanly, and does nothing. So the fix includes a general guard:
`test_kb_migration_env_contract.py` parses every `os.environ` read in the four
handlers and in `kb_backend`, and asserts the construct sets each one, with
documented exemptions for Lambda-provided variables, defaulted tuning overrides,
fallback links, and modules outside the handlers' import closure. It also fails on
any kb-migration variable the construct publishes that nothing reads — which is
precisely how this defect would have been caught before deploy.

Both mutations verified caught: restoring the MANAGED_KB_ prefix on the worker
name, and publishing the retention window under the unread spelling.

Tests: 624 infra, 385 backend supply-chain + lambdas.
…-worker-env

fix(kb): give the dispatcher the worker name it actually reads
The first migration to reach the worker failed:

  AccessDeniedException: not authorized to perform bedrock:TagResource
  on resource arn:aws:bedrock:us-west-2:...:knowledge-base/*

`CreateKnowledgeBase` is called WITH tags, and AWS authorises the tagging as a
separate action from the create. The grant had `bedrock:CreateKnowledgeBase`, so
it reviewed as complete — the missing permission only appears at the moment a
real knowledge base is created, which is the first thing nobody had done yet.

Those tags are not decoration. They are what the reconciler and
`scripts/teardown/managed-kb.sh` match knowledge bases on, so creating them
untagged would be worse than failing to create them: an orphan nothing can find.
Failing closed here is correct behaviour, it just needed the permission.

Also adds `bedrock:ListTagsForResource`, missing for the same reason and with a
quieter failure mode: `tombstones.iter_project_knowledge_bases` reads tags to
decide what belongs to this project and fails closed on a read error, so without
it every knowledge base looks untagged, matches nothing, and the daily orphan
sweep reports a clean account forever.

Both on `knowledge-base/*` — at create time there is no ARN to scope to, which is
what the failing request was evaluated against.

The existing test asserted the action list with `toEqual`, so it caught the
addition and forced this to be deliberate; that whitelist is why a stray Bedrock
permission cannot creep in unnoticed (spec defect 8). Two further tests pin *why*
each action is needed rather than just that it is present, and both mutations are
verified caught.

Tests: 626 infra.
…ag-permissions

fix(kb): grant the tag permissions provisioning and reconciliation need
Approve refused on a private agent showed the backend's message twice:
inline on the page, next to the button that was just pressed, and again in
the global toast in the corner. The toast is the worse copy of the two —
further from the control, and it disappears on its own.

Opt the review flow out via the existing SUPPRESS_ERROR_TOAST context
token: the submission read, the diff, the review decision, and the
withdrawal decision. Each of those already renders the backend's own
message inline, and the diff's is load-bearing — it distinguishes "this
submission predates snapshots" from a transport failure, which a toast
would flatten into "something went wrong".

Deliberately not applied service-wide, and there is a test pinning that:
takedown has no inline error region on the Listings page, so the toast is
its only surface and silencing it would turn a visible failure into a
silent one. A call earns the opt-out by having inline UI, not by being in
this service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the toast removal on a running stack showed the inline message it
was supposed to leave behind renders at the top of the page, 565px from the
Approve button that produced it.

The decision bar is sticky and the read above it is not, so on a submission
with real instructions — this test agent had none, which is why it looked
fine — the reviewer presses Approve at the bottom of a scrolled page and the
explanation is off-screen above. That is the gap the global toast was
covering, and removing the toast without moving the message would have
turned a duplicated failure into a silent one.

Split the two error regions, because they are read at different moments and
from different scroll positions: a load failure is the first thing on an
otherwise empty page and stays at the top; a refused decision now renders
inside the sticky bar, directly above the buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generated by the kaizen-research skill. Top 5 ideas appended to
docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated by kaizen-review-prep. Ranked agenda for the 10-15 min decision pass;
queue updated with this pass's resolutions.

Executed in the queue (evidence-backed, per research/2026-08-28 Top 5 #4):
- Retired the [2026-08-14] tool-mutation probe entry as "premise not
  substantiated" - Anthropic's caching docs contradict it and name no beta.
  It was last review's recommended #1.
- Struck both MCP Apps verification prerequisites (both answered in favor of
  code we already ship) and down-ranked the entry.
- Struck the stale "blocked by Strands #3758" caveat on the cookbook entry
  (Python-side fix shipped in 1.53.0 via #3858).

Queue: 39 -> 38 open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2026-08-28

chore(kaizen): weekly research scan 2026-08-28
…26-08-28

chore(kaizen): weekly review prep 2026-08-28
The upload page advertised "JSONL, CSV, or TXT" and accepted four
extensions, but train.py only ever looked for a .csv. A JSONL dataset —
the format the copy names first — uploaded fine, dispatched fine, then
died on the GPU with `No CSV file found in /opt/ml/input/data/train`
about five billed minutes in. The user lost the time, the quota and
$0.12, and got a SageMaker AlgorithmError instead of a reason.

Teach the trainer JSONL and JSON alongside CSV, and validate that the
"text" and "label" columns the page promises are actually present.

The reader dispatch is a table rather than an if/elif chain so the
supported-format contract can be asserted without importing pandas,
which exists only inside the SageMaker training container. That absence
is why the loader had no test coverage and why this survived: the old
tests could only reach find_csv_in_channel, never the read itself.

Drop .txt rather than support it — a training record needs both a text
and a label, and a newline-delimited text file cannot express the label
without guessing a delimiter. It stays valid for inference input, which
is unlabelled, so that page is untouched.

Reject unreadable formats at /presign and again at POST /jobs. The
second gate is the one that matters: it is the last point before
SageMaker provisions a GPU, so a doomed dataset can no longer cost
anyone five minutes of ml.g5.xlarge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard reported $0.00 and 0 jobs for every period while jobs were
being billed, with the cost sitting in plain sight on the records it was
meant to aggregate:

    {"status": "COMPLETED", "billable_seconds": 300, "estimated_cost_usd": 0.1175}
    {"status": "FAILED",    "billable_seconds": 296, "estimated_cost_usd": 0.1159}

Three separate faults, each of which hid the next.

The StatusIndex GSI partition key is compared case-sensitively, and the
query used SageMaker's "Completed"/"Stopped" spelling. Records store
"COMPLETED"/"STOPPED" — routes.py maps between them on write — so the
query matched nothing, every time. Against real dev data: "Completed"
returned 0 rows where "COMPLETED" returned 2.

FAILED was excluded entirely. AWS bills a job that dies partway through,
so leaving it out understates spend even once the casing is right. The
user-facing quota counter already charges for failures; the admin view
now matches it.

Training and inference records share this table and this index, and only
the inference query filtered by job_type. Fixing the casing alone would
have exposed that: the training query returns the inference row, whose
record has no model_id, so _item_to_dict raises KeyError and the
dashboard 500s. Filter training on the JOB# sort-key prefix.

Adds the coverage this endpoint never had — the reason three faults sat
here undetected. Verified against dev: 1 training COMPLETED, 1 training
FAILED, 1 inference COMPLETED, each counted once, totalling 826s /
0.229h — matching the independent quota counter exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fine-tuning has been unreachable in every deployed environment. The
tables, bucket, SageMaker execution role and IAM grants all ship
unconditionally, but FINE_TUNING_ENABLED — which mounts the
/fine-tuning and /admin/fine-tuning routers, and defaults to "false" in
Python — was never set on the app-api container. Live deployed dev:
/api/sessions returns 401, /api/fine-tuning/access returns 404.

What made this hard to see is a name collision. A repo variable
CDK_FINE_TUNING_ENABLED exists and reads "true", so the settings say the
feature is on. But that flag gated whether the SageMaker *stack*
deployed, and its consumer was deleted in the single-stack migration
(#396) — "deploy-everything-always". It has had no reader since.

Three links were missing, not one; wiring any subset still yields
nothing. config.ts now resolves the flag, the app-api construct sets it
on the container, and platform.yml forwards it — the workflow passed no
fine-tuning variables at all. CDK_FINE_TUNING_CORS_ORIGINS was never
forwarded either, which is why the bucket's origins have only ever come
from the global CDK_CORS_ORIGINS.

Same treatment for FINE_TUNING_DEFAULT_QUOTA_HOURS, whose repo variable
(10) was equally stranded. Absent, it defaults to 0 = whitelist-only, so
users would meet a 403 rather than the intended automatic grant — a
quieter failure than the 404, and a likely next bug report.

Default ON with a kill switch, following the agentMarketplace idiom: an
unset Actions variable arrives as an empty string, so only the literal
"false" disables. Switching it off leaves storage untouched, so no
dataset or trained model is orphaned.

The config fields are required rather than optional, which immediately
surfaced ten hand-built fineTuning: {} literals in the CDK tests. That
is the type system catching exactly the omission behind this bug, so
they are filled in rather than the fields made optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…review-duplicate-error-toast

fix(marketplace): stop the review flow reporting failures twice
…formats-costs-and-runtime-flags

fix(fine-tuning): make the feature work end to end
`instance_type` arrives straight off the request body as a free-form
optional string, unvalidated, on both the training and inference create
paths. `calculate_cost` resolves it against INSTANCE_COST_PER_HOUR with
a 0.0 fallback, so anything outside that eleven-entry map runs real GPUs
and records $0.00 spend — the same invisibility the StatusIndex casing
bug produced, arriving by a different route.

The quota does not bound it. It meters GPU-*hours*, not dollars, so the
same ten-hour allowance buys roughly $14 on the ml.g5.xlarge the catalog
offers, or several hundred on a larger unlisted instance, and the admin
dashboard reports zero either way.

Validate the resolved instance type on both create paths and 400 with
the supported list, mirroring the dataset-format guard. Validating after
resolution rather than on the request field also covers a bad value
reaching inference from a stored training job record.

Not reachable from the SPA, which renders instance type read-only — this
is an API-level hole, and worth closing before the default quota opens
the endpoint beyond the current whitelist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…validate-instance-type

fix(fine-tuning): reject instance types we have no price for
The textarea binds keyup to the caret-move handler so a click or an arrow
key re-derives the `@…` token, and `syncMentionToken` ended by resetting
`mentionActiveIndex` to 0. Arrow keys are preventDefault'ed in `onKeyDown`,
but their *keyup* still fired that resync — so every ArrowDown moved the
highlight down and immediately dragged it back to the first row, making the
menu impossible to walk.

Reset the highlight only when the token itself changed (query or start), so
typing still restarts at the first row while bare caret events leave the
selection where the user put it.

Also scroll the active row into view: the list scrolls at eight rows
(`max-h-72`) and the parent owns the keyboard, so arrowing past the fold
moved a highlight nobody could see.

Adds the composer's first spec, covering the keyboard path end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A mention rendered as plain prose inside the message bubble, so nothing
told the reader that `@Brand Deck Builder` was an address rather than
something they happened to type. Render mention runs `font-semibold
text-white` against the bubble's `text-white/90` body.

Matching is driven by the known agent names from `AgentMentionService`
rather than a `@\w+` pattern: agent names contain spaces, so a word
pattern would bold only the first word, and it would also bold `@here`,
npm scopes and email addresses. The list is the same session-cached one
the composer's `@` menu already warms, so this costs nothing on the
render path; the component calls `load()` so a cold reload straight into
a thread still bolds, and text renders plain until the names arrive.

The stored message is untouched — the literal `@Name` is still exactly
what was sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n-menu-arrow-keys

fix(chat): @-mention menu arrow keys, and set mentions apart in the message
Minor release on knowledge bases, marketplace governance, and fine-tuning.

- Bedrock Managed Knowledge Base migration lands inert behind nine
  CDK_MANAGED_KB_* flags, all default OFF (managed storage is $5.00/GB-month
  vs ~$0.15). Adds a Bedrock service role, four Lambdas on one image, a
  report-only reconciler, four alarms, and the sparse KbWorkIndex GSI.
- Marketplace admins can read, test-drive and decline a submission; new
  terminal `rejected` listing state.
- Fine-tuning was unreachable in every deployed environment (FINE_TUNING_ENABLED
  never set on the container). Wired here, so it turns ON with this deploy
  unless CDK_FINE_TUNING_ENABLED=false. Three billing/format bugs fixed with it.
- Live RAG, ungated: queries clamp at 10,000 chars on both backends, and the
  document-status filter now fails CLOSED.
- Chat: arrow keys walk the @-mention menu; mentions render as an address.
- RBAC: JWT role mappings accept IdP group names containing spaces.

Deploy: set CDK_TAG_ENVIRONMENT, then platform.yml, backend.yml (two new
kb-migration jobs), frontend-deploy.yml.
@philmerrell
philmerrell requested a review from a team August 28, 2026 20:02
# unavailable upgrade card would be a strictly worse outcome. Logged at
# error so the failure is not silent.
logger.error(
f"kb {assistant_id}: could not derive upgrade status: {exc}",
if await bump_last_used_at(input_data.rag_assistant_id):
await resume_inactive_policies(input_data.rag_assistant_id)
except Exception as bump_err:
logger.warning(f"lastUsedAt bump failed for assistant {input_data.rag_assistant_id}: {bump_err}")

from apis.shared.assistants.listing import is_on_shelf

logger = logging.getLogger(__name__)
rather than reporting one. What an adapter *must* guarantee is that its
``relevance`` values agree with the order it returns.
"""
...

async def ingest(self, kb_ref: str, source: DocumentSource) -> None:
"""Index ``source`` into the knowledge base."""
...

async def delete_document(self, kb_ref: str, document_id: str) -> None:
"""Remove every trace of ``document_id`` from the knowledge base."""
...

from apis.shared.assistants.listing import is_on_shelf

logger = logging.getLogger(__name__)
rather than reporting one. What an adapter *must* guarantee is that its
``relevance`` values agree with the order it returns.
"""
...

async def ingest(self, kb_ref: str, source: DocumentSource) -> None:
"""Index ``source`` into the knowledge base."""
...

async def delete_document(self, kb_ref: str, document_id: str) -> None:
"""Remove every trace of ``document_id`` from the knowledge base."""
...
try:
bc.reserve(ASSISTANT_ID, APP_KB_ID, n, CAP)
accepted.append(n)
except bc.ByteCapExceeded:
# Commit half the time so both counters move.
if n % 2 == 0:
bc.commit(ASSISTANT_ID, APP_KB_ID, n)
except bc.ByteCapExceeded:

try:
run_migration(record, aws, clock)
except Interrupted:
CDK_FINE_TUNING_DEFAULT_QUOTA_HOURS is 0 (whitelist-only), not 10. The
routes mount on this deploy, but a user with no explicit grant in the
fine-tuning-access table still gets a 403 and no grant is auto-provisioned.
@philmerrell
philmerrell merged commit f77b2b9 into main Aug 28, 2026
12 checks passed
@philmerrell
philmerrell deleted the release/1.16.0 branch August 28, 2026 20:38
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.

4 participants