From e01bd1bceb973289db6c64337c4d7777dd95ff73 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Mon, 14 Sep 2026 23:11:37 -0600 Subject: [PATCH] Release/1.22.0 (#1117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp-apps): keep tool-result content on app-initiated tools/call `_serialize_content` read the result's content with `getattr`, but Strands' `MCPToolResult` extends `ToolResult`, a TypedDict — so what `call_tool_sync` returns is a plain dict at runtime and the attribute lookup found nothing. Every app-initiated tools/call therefore relayed `content: []` back to the iframe. The failure was silent end to end: app-api returned 200, inference-api returned 200, and the MCP server had really run the tool, so a write took effect while the App received nothing to render. Any MCP App that re-reads state after an edit appeared frozen. Handle the dict shape alongside the attribute one, mirroring the `isinstance(result, dict)` branch `_is_error` already has. The existing fakes in the dispatch tests are objects carrying a `.content` attribute, which is why the attribute-only path looked correct; the added test uses the dict shape the client really returns, with untagged Strands content blocks. Co-Authored-By: Claude Opus 5 Signed-off-by: Phil Merrell * fix(mcp-apps): revive a torn-down MCP session for app-initiated calls An app-initiated tools/call arrives between turns. `routes.py` rebuilds the conversation's agent first, but with `cache_write=False` it reads a cached agent — and Strands tore that agent's MCP client sessions down when the turn that built them ended. `_resolve_client` then hands back the client the UIToolCatalog recorded at some earlier build, so `call_tool_sync` raises: MCPClientInitializationError: the client session is not running. which becomes AppToolCallError(502) and reaches the App as a 502 Bad Gateway. It presents as intermittent because a call made while the turn is still streaming finds the session alive. Wrap the call so the client is reconnected for its duration and left as it was found. A session that is already live belongs to an in-flight turn and is used as-is, never stopped here. Overlapping app calls against the same client share one revived session through a refcount, so no call has the connection closed underneath it. Note `_resolve_client` takes `agent` and does not use it; resolving the live client from the freshly built agent would be the deeper fix, but it reaches into how tool providers are held and cached. This keeps the blast radius at the dispatch boundary. Co-Authored-By: Claude Opus 5 Signed-off-by: Phil Merrell * fix(kb): engine-aware managed context cap (8,000) — task 16.1 The 2,000-char MAX_CONTEXT_CHARS was sized for Docling chunks; Bedrock's are ~3x larger, so on the managed backend only ~1 of top_k=5 reranked chunks cleared the cap — top_k=5 became top_k=1 at the model, producing materially wrong answers (HANDOFF §5.40, e.g. a Major-Core course called an elective). Add resolve_context_cap (managed 8,000 / legacy 2,000) keyed on the same resolve_engine_for the backend resolver uses, wire both retrieval call sites, and pin the split with mutation-tested guards in test_kb_backend_parity.py. Amend Requirement 3.2 (was 2,000 on both) to an engine-aware cap: the asymmetry restores parity in chunks-reaching-the-model, not characters, sized from eval §13.6 (8,000 = all five managed chunks fit, ~966 extra input tokens/turn). Validated end-to-end on a prod-derived KINES advising corpus re-created in dev via scripts/local-dev/kb-cap-benchmark.py. * fix(kb): fail closed when no chunk carries a document_id — task 16.3 _filter_vectors_by_document_status opened with `if not doc_ids: return vectors` — the one fail-OPEN line left in an otherwise fail-closed function (§5.33). A non-empty batch where every chunk's document_id was absent/empty bypassed the DynamoDB status check and was served unverified, including deleted content. Now returns [] and emits METRIC_STATUS_FILTER_FAIL_CLOSED like the other unprovable paths; an empty input stays an empty result with no metric. Guard test_filter_fails_closed_when_no_chunk_carries_a_document_id, mutation-tested. Requirements 5.1/5.2 already mandated this; the path was overlooked. * docs(kaizen): queue Strands Snapshots + AgentCore workspaces, Unlocks-led Two entries added to the kaizen review queue, both framed on the capability-unlock lens rather than subtraction: - AgentCore Runtime workspaces: filesystemConfigurations on CreateAgentRuntime (sessionStorage / s3FilesAccessPoint / efsAccessPoint / capacityProviderVolume), verified against pinned botocore 1.43.68. Recommends shipping the s3FilesAccessPoint bridge over the existing user-files layout and deferring sessionStorage behind the deletion-path and durability gates. - Strands Snapshots: already present in pinned strands-agents 1.51.0. Led by branch/regenerate — a capability the SPA does not have at all — with the four-candidate subtraction audit recorded as a labelled negative result so it is not re-investigated. Both carry the dual-lens correction Phil made on 2026-05-10, which was prompted by this same AgentCore filesystem feature being written up subtraction-first. Co-Authored-By: Claude Opus 5 * fix: retain MCP App UI resources per conversation Leaving a conversation with an MCP App and navigating back dropped every App frame to a plain tool card until a hard refresh. Two things collided. `session.page` reset `McpAppStateService` on every route change, and the only thing that re-seeded it — the `uiResources` sidecar on `GET /messages` — rides on a request that `loadMessagesForSession` deliberately skips once a conversation's messages are cached. Since `MessageMapService` never evicts, the second visit to a conversation always hit that short-circuit, so the reset had no way back. A refresh worked only because it destroyed the message cache. Re-key the registry `sessionId -> toolUseId -> resource` and retain every conversation for the SPA session, mirroring the message cache. Reads are scoped to the viewed conversation, so a `toolUseId` can only resolve inside the conversation that produced it. Iframe teardown is unaffected: frames unmount with their message-list components. Also drop the `isViewedSession` gate on `onUiResource` / `onToolInputPartial` and record under the streaming session's own id. That gate existed only because of the reset; with retention it became harmful — an App produced by a conversation streaming in the background would have been discarded for good, since the inline `ui_resource` event never re-streams and the persisted replay rides on the request navigate-back skips. Verified against the dev backend: navigate-away-and-back keeps the frame (with no `GET /messages` on the return trip, confirming the mechanism), hard refresh still hydrates, two App conversations retain independently, and an App produced while its conversation streamed in the background is present on return. Tool rails, artifacts and app-initiated cards unchanged. Co-Authored-By: Claude Opus 5 * chore: pin the frontend preview to port 4200 The local app-api builds its CORS allowlist explicitly from `CORS_ORIGINS` with `allow_credentials=True` (which forbids a wildcard), and the Cognito localhost callback is registered for `http://localhost:4200`. A preview moved to any other port therefore has every API call blocked and cannot complete a login — a failure that surfaces as a broken app rather than a port conflict. `autoPort: false` makes the preview fail loudly on a busy port instead of silently relocating to one that cannot work. Co-Authored-By: Claude Opus 5 * fix(mcp-apps): resolve the OAuth token for app-initiated tool calls An App-initiated `tools/call` runs the MCP client directly instead of the agent's tool loop, so `BeforeToolCallEvent` never fires and `OAuthConsentHook` — the only thing that warms `oauth_token_cache` — never runs. The client's token provider is just a cache read, so on any container that has not served a model-driven turn for that (user, provider) the request goes out with no Authorization header at all: after a page reload lands the call on a fresh runtime (or a restarted local uvicorn, or once the cache's 3000s TTL lapses) every button in an embedded App fails. It fails silently rather than 401-ing because a server that accepts an unauthenticated `initialize`/`tools/list` — Google Tasks does — still registers the tool, so the App renders and only the calls fail, with the server's own "isn't connected yet" text. No 401 means `_recover_oauth_preflight`, which does warm the cache from the vault, never fires; a server that 401s its `tools/list` would have self-healed. `_ensure_oauth_token` now repeats the hook's warm-the-cache half explicitly: resolve the provider from the MCP client, honour the durable disconnect flag, then `resolve_token_or_consent_url` to warm the cache or report that consent is required. Two deliberate departures from the hook: * Consent-required answers 409, never 401. The SPA's error interceptor treats any 401 as an expired BFF session and redirects to login, so a 401 would sign the user out over an unconnected connector. 409 is already this codebase's "needs connecting" status (file-source browser, export dialog). * An auth-shaped failure clears the cached token but does not retry. An app call is whatever button the user pressed — `complete_task` — so a regex-triggered retry could apply a mutation twice. The next press misses the cache and re-resolves. The auth-failure regex moves to `apis/shared/oauth/auth_failure.py` so the hook and the dispatch cannot drift on what an auth failure looks like. Co-Authored-By: Claude Opus 5 * feat: collapse app-run tool cards behind the App frame's header App-initiated tool calls (MCP Apps PR #6) hydrated on reload as one static card apiece, stacked at the tail of the conversation. An interactive App like the Google Tasks board runs a tool on nearly every gesture, so a refresh produced a wall of "RAN BY APP" cards — detached from where they happened, duplicating state the re-mounted App already shows, and growing without bound. They now surface where they belong: on the App frame that ran them, grouped by the originating tool-use id the card store already records. The header carries a count chip ("3 actions", tinted and annotated when something failed); expanding it shows successes collapsed into a single `board_snapshot ×6, update_task ×2` summary line, with each failure listed separately alongside its error text — failures being the question this record exists to answer. Cards whose frame can't render (no mcp-sandbox origin → no frame) would otherwise vanish silently, so the message list keeps a fallback box for those orphans, summarized the same way. Provenance-only, as before: nothing here reaches the model or the prompt. Co-Authored-By: Claude Opus 5 * feat(mcp-apps): revalidate an App's UI resource when the agent is already up The App HTML the SPA re-mounts on reload is whatever `resources/read` returned when the tool first ran, replayed verbatim from its `UIRES#` row along with the CSP and permissions captured at the same moment. A server that ships a new App version — or tightens the policy its App runs under — never reached conversations that already existed, and we had quietly become the durable store of record for a resource that belongs to the server. Re-reading needs a live MCP client, and the only path to one is a built agent. Revalidating on conversation open would therefore add a full agent rebuild (76% of sessions bypass the agent cache) to a page load that runs no model turn, for every App whether or not anyone touches it. So this piggybacks instead: an app-initiated tools/call has already built the agent and revived the client, which makes the extra read close to free. The refreshed shell lands on the next load rather than the current one. That is the deliberate trade — it converges for the Apps people actually use, and costs nothing for the ones they don't. Bounded and non-blocking: one refresh per resource per process, dispatched off the response path so the App's call is never slowed, and silent on every failure — a server that is down must not blank an App that still works. `get_provenance` projects only the producing tool name and the message anchor, never the stored HTML the refresh is about to replace. Co-Authored-By: Claude Opus 5 * fix(mcp-apps): give an App a real chance to flush before teardown SEP-1865 has the host send `ui/resource-teardown` before tearing a resource down for any reason, and wait for the response where it can, so the App can save its state to its own server. That matters more here than it looks: the host is deliberately not the store of record for App state, so a teardown the App never hears about is state nobody saves. `dispose()` defeated exactly that. It fired the notification and then, in the same tick, removed the message listener and rejected every pending request — so the ack landed on nothing, and an App that answered teardown by calling a save tool had its postMessage dropped on the floor. The comment said "we're going away regardless of the ack", which was true and was the bug. Two changes: * The bridge stays attached through a grace window after sending teardown, and `dispose()` returns a promise that settles on the ack or when the window expires. Inbound routing now gates on a new `detached` flag rather than `disposed`, so the window is live on purpose. The App's save call is proxied over HTTP from the host page, so once its message reaches us the request outlives the iframe. * Teardown fires at navigation intent, not just component destroy. By the time Angular destroys the frame it is removing the iframe in the same tick and the View is gone before it can run anything, so a new registry of live bridges lets the conversation-change path notify every open App while its iframe is still alive. Fired, not awaited: the value is in the timing rather than the wait, and that effect is synchronous. Known limit: a hard refresh or tab close still gets no window, and a blocking wait on in-SPA navigation would need a route guard the app does not currently have. Reparenting the iframe to outlive its component is not an option — moving an iframe in the DOM reloads it, destroying the state this is trying to save. The existing dispose test asserted the same-tick detach; it now asserts the grace window, which is the behavior change. Co-Authored-By: Claude Opus 5 * docs(kb): mark §5.40 (PR #997) and §5.33 (PR #998) resolved in HANDOFF Task 16.1 (engine-aware managed context cap) and 16.3 (fail-closed status filter) both shipped and merged to develop. Flip their STILL-OPEN markers to RESOLVED across the §0 intro, the §5.33/§5.40 entry headers, and the §6 open table. §5.41 (diagram answer quality, task 16.2) remains the open answer-quality item. * feat(kb): dead-letter document reconciler (task 16.5, HANDOFF §5.37) The ingestion consumer is the only writer of DOC# status. When its event dead-letters (Lambda async retry capped at 2), a document Bedrock already indexed is left parked non-terminal forever, and the retrieval filter serves only 'complete' -- so its content sits in the KB fully retrievable and invisible to every query. Two such docs occurred in dev; both needed manual repair. Add document_reconciler.py: the missing second writer. Daily, it finds DOC# rows stuck non-terminal (uploading/chunking/embedding) past a 60-minute grace gate, probes Bedrock per document, and drives a stranded-but-retrievable doc to 'complete' (the §5.37 case). It reuses the consumer's own probes -- document_status, the equals-on-document_id retrievability search, its status-set constants, and set_document_terminal -- so §5.37/§5.38/§5.39 live in one place. FAILED -> failed; NOT_FOUND -> re-ingest from S3 (the scheduled form of task 14.4's one-click retry). Modelled on reconciler.py: ships DISARMED (MANAGED_KB_DOC_RECONCILER_ARMED, empty reads as off); per-run action limit applies in both modes so the report is trustworthy; grace gate is a pure function of the row's own updatedAt and fails closed; terminal/deleting rows are never candidates. Guards in tests/lambdas/test_kb_document_reconciler.py (61 tests), mutation-verified. Scheduling + IAM wiring + arming are a deploy-gated follow-up; the flag is exempted in the env-contract test's OPTIONAL_OVERRIDES until that lands. * feat(kb-migration): engine visibility — status vocabulary + Managed/Classic badge (task 16.4) (#1006) Engine-aware document status vocabulary (managed: uploading→processing→ready(+failed); legacy keeps chunking/embedding), one INFO line per query naming the served engine in the rag_service facade, and a Managed/Classic badge fed by a new engine field on UpgradeStatusResponse. Mutation-tested guards across backend + frontend. HANDOFF §6 / tasks 16.4. * feat(kb): wire the dead-letter document reconciler Lambda, nightly schedule and IAM (task 16.5) Adds the fifth kb-migration Lambda (document_reconciler.lambda_handler) to the shared one-image/five-functions construct, so the reconciler built in the backend PR actually runs. - Nightly EventBridge schedule: cron(0 9 * * ? *) (~02:00-03:00 America/Denver), ENABLED regardless of flags, because it ships report-only and report-only is read-only (same inverted convention as the KB reconciler). - IAM: grantDirectIngestion (GetKnowledgeBaseDocuments + IngestKnowledgeBaseDocuments) + grantRetrieval (bedrock:Retrieve) + documents-bucket read + assistants-table RW. Deliberately NOT grantProvisioning or PassRole: it reads KB_Records from DynamoDB, never ListKnowledgeBases, and never creates/deletes a knowledge base -- a strictly narrower footprint than the KB reconciler. - New flag MANAGED_KB_DOC_RECONCILER_ARMED (config.docReconcilerArmed), empty=off, forwarded to every function and threaded through load-env.sh; ships disarmed. - SSM function-name param + deploy-image-lambda-one.sh case + backend.yml deploy step so the out-of-band image swap reaches the new function. - Bootstrap stub (document_reconciler.py) + Dockerfile COPY keep the byte-stable five-handler image consistent; import-closure and env-contract tests updated. Full infra suite green (792), touched Python supply-chain tests green (21). Stacked on the backend PR (#1007); merge after it. No deploy in this PR. * fix(mcp-apps): carry app-tool errors across the AgentCore boundary An app-initiated `tools/call` that needs OAuth consent reported a deliberate 409 with "Connect the account, then try again". The user saw: Error — Received error (409) from runtime. Please check your CloudWatch logs for more information. inference-api runs behind AgentCore Runtime, which rewrites **any** non-2xx container response to a generic 424 and discards the body. Both the status and the human-readable message were destroyed, so: - `app_api/mcp_apps/routes.py` claimed to relay the status "verbatim (403 not-app-visible, 409 no consent)" — by then it was always 424. - `app_tool_dispatch.py` claimed "the SPA relays `message` verbatim" — the message was gone. - `mcp-app-proxy.service.ts` has no consent branch, so it rendered the raw runtime text. Verified live on dev 2026-09-08 while regression-testing #1000/#1001/#1002. #1001's detection logic was correct all along; only its reporting was lost. ## The fix inference-api answers **200** with an `appToolError` envelope carrying the code and message; app-api restores the real status before replying. The SPA's contract is unchanged — it still sees 403/409/502 with `{"error": ""}` — so only the one hop crossing AgentCore changes shape, and no frontend change is needed. Applied to both proxied directives: `app_tool_call` and `app_context_update` (identical flattening). Two properties the envelope enforces independently of its callers: - **Never relays a 401.** The SPA's `error.interceptor` treats any 401 as an expired BFF session and signs the user out, so an unlisted or malformed status collapses to 502 rather than letting upstream choose. - **An enveloped error persists no provenance card.** The card write is gated on the upstream's 200, and an envelope now arrives *with* a 200, so the check runs first. ## Testing - 30 new guards in `tests/shared/test_mcp_app_error_envelope.py`, plus relay tests on both routes. - Mutation-tested, all mutants compiling: reverting the 200 to the real status fails `test_error_response_is_http_200`; admitting 401 to the whitelist fails 5 named tests; moving the envelope check after the card write fails `test_enveloped_error_persists_no_provenance_card`. - Full backend suite: 7786 passed. (8 pre-existing `tests/fine_tuning` failures are a missing `pandas` in the local venv, unrelated.) - `ruff` clean on every changed file. Not verifiable locally: a local uvicorn talks to app-api directly with no AgentCore in the path, so the flattening cannot reproduce — which is why this survived #1001's unit tests. Needs a dev deploy to confirm end to end. Co-Authored-By: Claude Opus 5 * feat(tools): browse_web — drive a real browser via AgentCore Browser Adds a `browse_web` tool backed by the AgentCore Browser resource that PlatformStack already deploys but nothing consumed: the runtime role has the browser IAM actions and `BROWSER_ID` in env, and the only reader was a startup log line. Zero new dependencies. `websockets==16.0` is already in the image, so the CDP layer is hand-rolled rather than adding Playwright and its bundled Node driver to the inference-api container for auto-waiting and a selector engine we mostly don't use — JS evaluated in the page does the same job. `cdp_client.py` is the seam if richer interaction is ever needed. Cost posture, since a browsing transcript is the classic unbounded per-turn payload: every action's output is capped before it reaches the model, and a screenshot is only ever taken when the model explicitly asks — vision tokens are the most expensive thing this tool can emit, so it is never automatic. Seeded `enabledByDefault=False` and gated by `BROWSER_TOOL_ENABLED`. Session lifecycle follows the "never cache session state on an agent instance" rule: the browser session *id* lives on Strands `agent.state` (as `app_context_dispatch` does), while the live socket is process-local keyed by that id — so a second cached agent for the same conversation reconnects to the same remote session instead of starting a second one. `add_init_script` (CDP `Page.addScriptToEvaluateOnNewDocument`) is included and probed but unused: it is the hook a future WebMCP driver would need, per docs/specs/webmcp-host-spa-tools.md. Not verified against live AWS — the dev-ai SSO token was expired and the device grant needs an interactive session. `scripts/probe_agentcore_browser.py` runs the five checks that unit tests cannot. Co-Authored-By: Claude Opus 5 * fix(tests): derive seeder tool counts from DEFAULT_TOOLS Adding browse_web to the bootstrap seed broke three assertions in TestSeedDefaultTools that hardcoded "9 tools". The counts now come from len(DEFAULT_TOOLS), so the invariant under test is the real one — every entry in the seed list gets created or skipped — instead of a literal that has to be bumped by hand whenever a tool is added. Also pins browse_web's seeded fields the way the other tools are pinned, including enabledByDefault=False: that flag is the cost guard, not a default worth drifting. Co-Authored-By: Claude Opus 5 * chore(deps): strands-agents-tools 0.8.6 → 0.8.8 (calculator sandbox escape) 0.8.8 closes a sandbox escape in `strands_tools/calculator.py`. The AST allowlist trusts string literals as positional arguments to a few constructors that parse them as a plain name or numeric literal (Symbol, symbols, Rational, Integer, Float). Through 0.8.6 that check ignored the call's keywords, so `symbols('...', cls=N)` rerouted `symbols` to apply N — and therefore sympify — to the string, re-parsing it outside the restricted namespace. 0.8.8 adds `_has_only_assumption_keywords()`: a string positional is trusted only when every keyword is a boolean assumption flag, and `**kwargs` unpacking is untrusted because it can smuggle in `cls`. This matters here because `calculator` is registered in `create_default_registry()` and seeded `enabledByDefault=True`, so it is on for every user and evaluates model-supplied expressions. Verified by diffing the 0.8.6 and 0.8.8 wheels rather than trusting the monorepo-wide release notes. Six files differ; we import only `strands_tools.calculator`, whose diff is exactly this fix. The other five (http_request, mem0_memory, mongodb_memory, think, use_aws) are unreachable from our import graph. Adds a regression test pinning the boundary to the installed wheel — it fails on 0.8.6 and passes on 0.8.8, so a downgrade or resolver drift cannot silently reopen the escape. Co-Authored-By: Claude Opus 5 * fix(mcp-apps): let the consent message reach the toast Follow-up to #1009, from validating it on dev. #1009 did what it claimed — the status and message now survive the AgentCore boundary intact: POST /api/mcp-apps/proxy-call → 409 {"error":"Authorization required for 'google-tasks'. Connect the account, then try again."} But the user still didn't see it. The toast read: Conflict — The request conflicts with the current state. `ErrorService.handleHttpError` looks for a message under `detail`, `error.detail`, `error.message`, or `message`. app-api returns `error` as a plain **string**, which matches none of those, so it fell through to the generic per-status fallback while the real text sat unread in the body. That also explains the pre-#1009 symptom: AgentCore's 424 body used the key `message`, which *did* match — so the useless "check your CloudWatch logs" text was rendered for exactly the reason the useful text was not. ## The fix `app_tool_error_body()` emits the same text under both keys: - `error` — `McpAppProxyService` reads `err.error?.error` for the iframe's JSON-RPC reply (unchanged). - `detail` — what `ErrorService` renders, and FastAPI's own `HTTPException` shape, which the rest of this router already emits. No SPA change: the fix is to speak the key the SPA already reads. ## Testing - 2 new guards on the body shape; both relay tests now assert `detail`. - Mutation-tested: dropping `detail` fails 4 named tests across all three files; the mutant compiles. - 46 passed across the envelope + both route suites. `ruff` clean. Co-Authored-By: Claude Opus 5 * chore(deps): upgrade strands-agents 1.51.0 -> 1.55.0 Migrates the deprecated model-level `cache_tools` key to `CacheConfig(tools_ttl=...)` and fixes the Nova Sonic provider rename that 1.55.0 makes, which would otherwise have silently disabled voice. Prompt-cache contract (the governing constraint here) is unchanged, proven two ways rather than by code reading: - Offline: the full formatted ConverseStream request for our production config is byte-identical between 1.51.0 and 1.55.0 (same request SHA). - Live against dev Bedrock: a request formatted by 1.55.0 READ the exact cache entry (7,059 tokens) that the same request formatted by 1.51.0 had just written. Bedrock itself confirms the cacheable prefix did not move. `cache_tools` -> `tools_ttl` `_warn_on_deprecated_cache_tools` fires on the old key in 1.55.0. With `cache_config.ttl` unset, `_build_tools_cache_point` resolves `tools_ttl=True` to the same bare `{"cachePoint": {"type": "default"}}` the deprecated path emitted, so the toolConfig tail — which sits inside the cached prefix — is unchanged. The unsupported branch now pins `tools_ttl=False` explicitly instead of falling through the deprecated key. New auto-injected system cache point 1.55.0 adds `_should_cache_system()`, guarded by `not any("cachePoint" in block ...)`. `AgentFactory.create_agent` already appends its own whenever `bedrock_cache_points_supported()`, and that predicate is equivalent to the SDK's, so no path gains a second point. Audited every system-prompt path: the chat agent goes through the factory; voice uses BidiNovaSonicModel (no cache_config); `/chat/api-converse` uses raw boto3 on the Bedrock branch and OpenAI-surface models otherwise; there is exactly one `BedrockModel` construction site. `system_prompt_ttl` is left at its default True as a safety net for any future path that bypasses the factory. `_apply_system_cache_ttl` is inert because `cache_config.ttl` stays unset. Nova Sonic provider rename (not in the release notes) 1.55.0 moved `models.nova_sonic.BidiNovaSonicModel` to `models.bedrock.BedrockNovaSonicModel` and flattened its constructor (`provider_config["audio"]` -> `audio`, `client_config["region"]` -> `region`). `voice_agent` imports the provider inside a `try/except ImportError` that degrades to `BIDI_AVAILABLE = False`, so the stale import would not have crashed — it would have silently turned voice off in the inference-api image, which does install `--extra bidi`. Adds a contract test that reads the pinned SDK's source rather than importing it, since CI installs `agentcore`/`dev` but not `bidi`. Also updates the sequential-executor cancellation canary: 1.55.0 moved the per-tool check from `agent._cancel_signal` to `Agent._observe_cancellation()`, same semantics plus an external-signal mirror. boto3 stays at 1.43.68 in all five files that pin it — 1.55.0 floors at >=1.26.0 and `bedrock-agentcore` is unchanged, so nothing forces it. Fixes we gain on the GPT-5.6 / Mantle Responses path: `length` now beats `tool_calls` (1.51.0 executed truncated tool arguments), `cachePoint` blocks are filtered instead of raising `TypeError` on a mid-session model switch, and document attachments use `filename`/`file_data` instead of `file_url`. Verified: full backend suite 7783 passed / 0 failed; a 3-turn dev session on 1.55.0 went first_write -> hit -> hit with constant toolConfigHash and systemPromptHash, no partial_miss, cacheRead:cacheWrite 9097:126 then 9223:34; a GPT-5.6 (Terra) turn with a text attachment read the document correctly. Co-Authored-By: Claude Opus 5 * feat(fine-tuning): add generative VLM fine-tuning (image-text-to-text) Adds a fourth task type so vision-language models like LLaVA and Qwen-VL can be fine-tuned. The three existing tasks all end in a softmax over a fixed class list, so a generative checkpoint was rejected at pre-flight: its "image-text-to-text" Hub tag matched no task's hf_pipeline_tags. The new task keeps the model's language head and learns to write the response, so it has no label column. Records are image/prompt/response; the result is still one CSV row per record, carrying an output column instead of one probability column per class, so the download and the result viewer are unchanged. LoRA rather than a full fine-tune, because a full fine-tune of the largest catalog entry is not possible on the fleet: ~550GB of optimiser state for a 34B model against 384GB on the biggest instance offered. The frozen base is quantised to 4-bit NF4 and only adapters train, so the artifact is a few hundred MB and vlm_adapter.json records which base it belongs to. Loss is masked to the response, measured by rendering each record twice and masking the prompt prefix; prompt_mask_limit clamps to len-1 because an all-masked row yields NaN loss that poisons the batch average. The SageMaker source dir is now packaged per DLC family. peft and bitsandbytes cannot go in the shared requirements.txt — bitsandbytes requires torch>=2.4 and the text container is torch 2.1, so one shared file would break dependency installation for every existing text job. The vlm family points at the same image as vision but carries its own dependency set, which also means a future VLM-only image bump cannot re-baseline the image classifiers. torch, transformers and peft are absent from the backend venv by design, so the collator cannot be unit tested there. The masking arithmetic is extracted to a pure helper and covered; the collator is guarded at runtime by check_collation, a two-record canary that runs before Trainer starts — the expensive failure is truncation cutting into the image placeholder run, which otherwise crashes minutes into a billed GPU. Verified: backend 7940 passed, frontend 2558 passed, and the pre-flight checked against the live Hub — llava-v1.6-34b-hf is accepted for the new task and still correctly rejected for the three classification tasks. Co-Authored-By: Claude Opus 5 * fix(fine-tuning): fit VLM context length to the model's real image cost The SmolVLM catalog entry could not train on its own defaults. Found by running a real job on dev: SmolVLM-Instruct spends 1377 tokens on a single image, against a default context_length of 1024, so truncation cut the image placeholder run to 891 and the processor rejected the batch. The image alone did not fit in the budget, let alone the prompt. LLaVA-1.6's AnyRes tiling and Qwen2.5-VL's dynamic resolution both go well past the 2048 those entries defaulted to, so three of five catalog models were affected. A fixed default cannot be right: the token cost depends on the checkpoint's tiling AND on the resolution of the images the user uploaded. So the trainer now measures instead of guessing — it renders a sample of records untruncated, raises the context length to fit, and logs the adjustment. It fails with a message naming the cause only when a single record genuinely exceeds the model's own maximum, which no context setting can fix. Defaults are raised too, so the measured path stays a safety net rather than the norm: 2048 for SmolVLM and LLaVA-1.5, 4096 for the three AnyRes/dynamic-resolution models. Headroom is close to free because the collator pads to the longest item in the batch, not to max_length. check_collation stays as the backstop and is what caught this: it fired 8 seconds after model load, so the failed run billed 472 seconds instead of a full training cycle. Co-Authored-By: Claude Opus 5 * fix(observability): alarm on Bedrock TPM quota per model, not account-wide `bedrock-tpm-quota-usage` compared `EstimatedTPMQuotaUsage` against 80 as though the metric were a percentage of quota. It is an absolute token count, so 80 was crossed by roughly one sentence of model output. In production the alarm was above threshold for 195 of 197 five-minute datapoints over 24 hours, clearing only when a period had no data at all - it was reporting whether anyone was using the product. It produced 205 state transitions in six days and was the source of very nearly all alarm traffic. Quotas are per model AND per inference profile, so the dimension-less account-wide roll-up had no single denominator to be a percentage of. Summing a 40,000,000-quota profile with a 200,000-quota one yields a number comparable to nothing, and it hides the model closest to its own ceiling: Claude Sonnet 4 runs at ~45% of a 200,000 quota, while Sonnet 5 carries 94% of the traffic at ~1% of 40,000,000. Replaced with one alarm per configured model, thresholded at `bedrockTpmQuotaPercent` (default 75) of that model's own quota. Quotas are operator-supplied and default to EMPTY. They are per-account and adjustable - this account has three increase requests on record, one still open - so no value is shippable, and an empty map creates no alarm rather than a confidently wrong one. `bedrock-invocation-throttles` remains the backstop and needs no quota configured. The quota map is the first non-scalar observability tunable, which exposed a transport hazard: `deploy.sh` runs `eval npx cdk synth ${CDK_CONTEXT_PARAMS}` and eval removes quote characters, so a JSON value passed the way every other `--context` value is passed arrives as `{model:40000000}` - unparseable, and it would have fallen back to the empty default creating no alarms with no error. The parser therefore also accepts a quote-free `modelId=quota,...` form, `load-env.sh` single-quotes the value, and it fails loudly on a value containing a single quote instead of degrading silently. The alarm description leads with "confirm the configured quota is still the live quota", because nothing checks that automatically; the runbook table in .kiro/steering/observability.md says the same. Verified: full suite 782 passing, 8 failing - byte-identical to the failure set on untouched origin/develop (shell executable-bit checks on Windows, a committed GSI snapshot, a kb-migration digest pin). No new failures, none disappeared, +7 tests. tsc clean; bash -n clean; eval quoting verified empirically against a control. * docs(kb): resolve task 16.2 — diagram answer quality understood, no code fix Closes §5.41 as a product/training matter rather than an engineering change. Re-measured on the live diagram corpus (ast-1a90784a7f18, 4-yr-flowchart-v2026.pdf): image extraction is one-directional (legacy 0 chunks, managed 5), but the vision model flattens the 2-D column layout at ingestion so per-column answers are confidently wrong (14 credits vs the chart's 19, mis-columned ENGR 220), and the task-16.1 cap fix does not rescue it at either cap. A text sidecar would work but this is a self-service platform where users build their own agents and won't know to convert a diagram — so the mitigation is guidance, not code. Ticks task 16.2, flips §5.41 + §0 intro + §6 table to RESOLVED. * ci(kb): forward CDK_MANAGED_KB_DOC_RECONCILER_ARMED to the platform deploy Completes the arming chain for the dead-letter document reconciler (task 16.5). #1008 wired the flag through load-env.sh -> config.ts -> the CDK construct, but platform.yml never forwarded the GitHub environment variable, so the flag could only be armed by hand-editing cdk.context.json or the Lambda env. Adds the missing env line (mirroring the other three managed-KB flags) and updates the comment to describe all four flags and both report-only-by-default reconcilers. Ships OFF by default like the others (unset var reads as false). * feat(kb): enforce managed-KB byte cap at document upload time (Req 12.11) Enforce the managed-KB per-owner Byte_Cap on the interactive upload path, the last byte-adding path that was uncapped (migration was already covered). Managed KBs only; legacy S3-Vectors KBs stay uncapped. - Request-time pre-check (documents/routes.py): reserve the client-declared size against effective_cap = min(per_owner_cap(elevated), per_kb_ceiling()) BEFORE creating the DOC# row / issuing a presigned URL; over-cap => HTTP 413 with the numbers (Req 12.12). Provisional only (Req 12.3). Release if a later step fails (Req 12.6). - Authoritative reconcile at ingestion (kb_migration/ingestion_consumer.py): on INDEXED+retrievable, take the true size from an S3 HEAD and commit / release the difference / reserve the shortfall; a real size that overshoots the cap fails the doc, deletes the orphaned S3 object and releases (Req 12.3/12.4). Every terminal failure path releases the reservation (Req 12.6). - Leak prevention: release on client-reported upload failure and on the stale auto-fail sweep (documents/services/document_service.py). - byte_cap.effective_cap() folds both caps into one atomic reserve (Req 12.1/12.5); byte_cap.settle_once() makes commit/release exactly-once across EventBridge redeliveries and racing failure paths. - kb-sync image: COPY observability/ (byte_cap->metrics->emf closure); build-one.sh SOURCE_DIRS kept in lockstep. Tests: new request-time route tests + ingestion reconcile tests (mutation-verified: dropping the request-time reject and dropping the ingestion release-on-failure each fail a named test). Property/boundary/supply-chain suites stay green. * feat(infra): per-environment app-api Fargate sizing via GitHub Variables App API sizing was pinned in cdk.context.json, so dev and prod shared one value. Production ran 2 x (0.5 vCPU, 1 GB) — 1 vCPU total — which saturated at ~100 concurrent logins on 2026-09-09, failing the container health check and taking a task out mid-burst (2 events, 40 rejected requests, 13x latency). Sizing is now per-environment via GitHub Variables, with cdk.context.json holding the fork default rather than BSU's production values. - config.ts: read the sizing knobs through the full precedence chain (env var > FLAT dotted context > nested context object). load-env.sh already emitted `--context appApi.cpu=...`, which sets the flat key 'appApi.cpu'; only the nested form was read, so every --context override was accepted and silently discarded. Switch `||` to `??` so a legitimate 0 is not swallowed. - platform.yml: CDK_APP_API_{CPU,MEMORY,DESIRED_COUNT,MAX_CAPACITY} added to the job-level env block (the job already carries environment:, and vars.* in a workflow-level env resolves to an empty string). - platform.yml: add infrastructure/cdk.context.json to the push paths filter; a sizing change there previously triggered no deploy. - nightly-deploy-pipeline.yml: its deploy-platform job deploys the stack too, so it needs these. Pinned literally (512/1024/2/4) rather than from vars.*, matching the existing CDK_DOMAIN_NAME: "" precedent — an ephemeral stack should not inflate in cost when production sizing changes. - cdk.context.json: fork default 512/1024 -> 1024/2048. A fork inheriting a config that falls over at ~100 logins is a bad out-of-box default. - Add test/app-api-sizing-config.test.ts covering all four precedence cases, including an unset GitHub variable arriving as ''. That case matters: desiredCount uses ??, so a parseIntEnv('') returning 0 or NaN would have taken the service to zero tasks. Verified: tsc --noEmit clean; both workflow YAMLs parse with the vars confirmed at job level on environment-scoped jobs; 252 tests pass across 8 suites (config, additional-coverage, api-security-headers, kb-migration, cloudfront-shared-cert, spa-frame-src-csp, observability-alb-ecs-alarms, and the new sizing test). Not included: the Variables themselves must be set in the GitHub Environments (prod 2048/4096/3/10, dev 512/1024/2/4), and a Variable change triggers no workflow — deploy via workflow_dispatch and confirm the resolved sizing in the synth log first. * fix(infra): grant app-api sagemaker:CreateModel for Batch Transform Fine-tuning inference has never worked from the deployed app. Batch Transform is a two-step API — CreateModel registers the trained artifact, then CreateTransformJob runs against it — and the task role was granted the job actions but not CreateModel, so every inference job died at step one with AccessDeniedException. It went unnoticed because the failure is invisible from a developer machine. CloudTrail shows every successful CreateModel in dev was called by a human's SSO credentials running app-api locally against dev data; the only call from the ECS task role is the one that surfaced this, and it was denied. `sagemaker:CreateModel` appears in no revision of the infrastructure. The ARN is the subtle part. sagemaker_service names the model `model-{job_name}` and job_name already starts with the project prefix, so the resource is `model/model--*` — the literal `model-` sits ahead of the prefix. A pattern written to match the training-job and transform-job ARNs looks correct and denies every call, which is why this is its own statement rather than more actions on the existing one. Grants only CreateModel: create_model is the sole model API the service calls. Models are left behind after each transform job, which is a tidiness issue worth a follow-up, not a reason to grant DeleteModel here. Requires a platform.yml deploy, not backend.yml. Co-Authored-By: Claude Opus 5 * feat(fine-tuning): checkpoint training jobs and resume on restart Two things restart a training job: a spot interruption, and SageMaker killing it at MaxRuntimeInSeconds — which the dollar-quota clamp makes routine, since a $14 balance buys 3.7h on the 34B instance while a real run needs far longer. Until now `save_strategy="no"` meant the adapter was written only after trainer.train() returned, so either restart produced nothing at all for the money already spent. Trainer now checkpoints periodically, SageMaker mirrors the directory to S3 via CheckpointConfig, and a restarted attempt resumes from whatever was restored. The interval is computed, not fixed. save_steps counts *optimizer* steps, and a 34B VLM trains at batch 1 with 16-step accumulation — a 90-sample epoch is about 6 steps. Against a hardcoded save_steps=50 the longest, most interruption-exposed job in the catalog would never checkpoint, while a text classifier with thousands of steps would checkpoint constantly. resolve_save_steps scales to the run's own step count, targeting ~10 checkpoints, so an interruption costs at most about a tenth of the run. save_total_limit=1 keeps the mirror bounded: resume needs the newest checkpoint and nothing else. Checkpoints go to a checkpoints/ prefix rather than inside the job's output prefix, where SageMaker writes the finished model.tar.gz — mixing a live-mirrored directory into that prefix makes it ambiguous which objects belong to the completed artifact. latest_checkpoint never raises. A checkpoint that cannot be read should cost the run its progress, not fail the job. --checkpointing=false restores the previous behaviour. Co-Authored-By: Claude Opus 5 * feat(kb): born-managed knowledge bases — provision on first upload (#1027) * feat(kb): wire MANAGED_KB_NEW_DEFAULT so new agents are born managed MANAGED_KB_NEW_DEFAULT (rollout ladder step 2) was a no-op: no backend code read it, and the app-api Lambda never received it. Wire both. - new_default_enabled() reads the flag (call-time, allow-list) mirroring migration_enabled(). - maybe_enroll_new_default() makes a newly finalized agent born managed by reusing enroll(): a new agent has no corpus, so migrating an empty corpus provisions the KB, converges instantly, and promotes through the proven, crash-safe, dispatcher-driven worker (catch_up covers docs uploaded mid-flight). Flag-gated, idempotent, and error-swallowing so it can never fail agent creation; catches UpgradeUnavailable (born-managed needs the migration worker running). Fire-and-forget from create_assistant (COMPLETE) and update_assistant (DRAFT->COMPLETE only). - infra: thread MANAGED_KB_NEW_DEFAULT into the app-api environment (config.ts already parses newDefault; platform.yml already forwards the GitHub var). - tests: backend flag-read + gating (incl. mutation guard that enroll is NOT called when the flag is off) + error-swallow; infra env-threading + explicit 'false'. Spec: .kiro/specs/managed-kb-migration/new-default-wiring.md. Ships dark (flag default off). Note: turning it on at fleet scale is gated by the ~10k Bedrock KB/account quota (managed is one KB per assistant) — see spec Risks. * feat(kb): born-managed provision-then-ingest on first upload Reworks this branch's approach. MANAGED_KB_NEW_DEFAULT still makes new agents born managed, but provisioning is now stacked onto the FIRST DOCUMENT UPLOAD rather than reusing enroll() at agent creation. Why the rework: enroll writes retrievalEngine=managed only at promotion, and the legacy ingestion handler skips a document only when the record ALREADY resolves to managed. So the very first document would be indexed on legacy, answered from legacy, and then indexed a second time on managed, with two writers racing over one DOC# status. Declaring the engine up front is what makes the first document go straight to the managed pipeline. First-upload timing also spares the ~10k KB/account quota (prompt-only agents and abandoned drafts never provision) and makes the creation-page playground WYSIWYG: what the author tests is the engine they ship. The flow: - Trigger (documents/routes.py, beside the PR #1019 byte cap): on first upload, create the KB_Record, records.adopt_managed_engine (a NEW conditional write, not promote_engine, which is guarded on migrationState=promote), persist the DOC# row as 'provisioning', queue the job. Three conditional writes, milliseconds, and it can never fail an upload. - Consumer DEFERS: the S3 event fires in seconds and CreateKnowledgeBase takes minutes, so with Lambda's async retry capped at 2 attempts the old "not provisioned" raise would dead-letter the first document of every born-managed agent (the S5.37 shape). It now returns a benign no-op for a born_managed record. - Provisioner (kb_migration/provisioner.py) owns the ingestion handoff: provision, then ingest the deferred documents by reusing ingestion_consumer.handle_object outright, so correctness never rests on the 2-try redelivery window. - Failure means legacy, never limbo: rollback_engine REMOVEs retrievalEngine and the waiting documents are failed with actionable copy, in that order. New migration state born_managed borrows the sparse work-key queue and the worker lease rather than growing a second dispatcher. The dispatcher now gates each work state on its OWN flag — born_managed on NEW_DEFAULT, the migration states on MIGRATION_ENABLED — so ladder step 2 provisions new agents and touches nothing that already exists; its EventBridge rule is enabled by either flag. Also: the first document IS byte-capped at request time (the ingestion reconcile commits the reservation, so committing without reserving would drive reservedBytes negative); new 'provisioning' status is non-terminal and non-retrievable; frontend reads it as "Provisioning knowledge base..." independent of the engine poll; the document reconciler treats it as a candidate for the long-horizon backstop. Kept from the previous approach: new_default_enabled(), the app-api env wiring and its infra test, the flag-read tests. Removed: maybe_enroll_new_default and its two finalize hooks. Tests: 27 new (tests/lambdas/test_kb_born_managed.py) with 4 mutation guards each verified by mutating and observing the failure - dropping the flag gate, the consumer DEFER, the failure-path rollback, or the per-state flag gating. 380 backend tests green, 72 infra, ruff clean, tsc clean. Ships dark (flag default off). Known cost recorded in the spec: pickup latency is up to one 15-minute dispatcher interval; the fix is a direct worker invoke from the API, which needs an ECS task-role grant and is deliberately not in this change. * docs(kb): record Lambda durable functions as the successor orchestration The queue machinery in this change (born_managed work state, sparse work keys, lease, 15-minute dispatcher tick, consumer DEFER, one-doc-per-invocation) exists only because a Lambda dies at 15 minutes and an S3 event gets 3 delivery attempts. Lambda durable functions (re:Invent 2025, Python supported) remove both constraints while keeping the saga in Python — so unlike Step Functions they cost no new AWS service and forfeit no in-process crash-convergence test. Records what it would delete, what survives any rewrite (the conditional-write guards and the declare-engine-before-the-object-lands ordering), the four real costs (a function cannot be converted to durable, qualified-ARN deploys, replay determinism, young SDK), and the recommended first move: spike born-managed alone rather than rewriting the engine. Documentation only. No behaviour change. * fix(connectors): allow metadata-only edits on discovery-URL providers The discovery guard in `update_provider` treated any non-None `oauth_discovery_url` / `authorization_server_metadata` as a discovery change, and a discovery change requires a credential rotation. The connector edit form round-trips the discovery URL on every save, so any edit — scopes, display name, icon, enabled — was rejected with "Discovery config can only be updated together with a credential rotation" for a provider that has one. The admin could not comply: the client secret is never readable back. Compare against the stored record instead, so the guard means what its error message says. Also make the form send the discovery URL only when it actually changed, matching the tri-state it already uses for the icon and the adapter mappings. This unblocks docs/specs/canvas-rubric-agent.md §8.2 step 4, which asks a connectors admin to add scopes to the prod `canvas-faculty` provider. Co-Authored-By: Claude Opus 5 * docs(specs): Canvas Rubric Agent — design, blockers, draft artifacts (#1028) * docs(specs): Canvas Rubric Agent — design, blockers, draft artifacts Specs a marketplace Agent that lets faculty create standards-aligned rubrics and publish them directly into Canvas, replacing the manage_rubrics.py Colab notebook workflow (course ID by hand, author a CSV, run a cell, import). The design leans on primitives that already exist: the deployed canvas_faculty MCP server for Canvas access, two skills for pedagogy and the Canvas field mapping (progressively disclosed, so they cost nothing until activated), and a knowledge base of program outcomes and CTL guidance so the agent can propose alignment instead of asking for it. Records four upstream blockers found while auditing dev and prod: - create_rubric drops rating long_description, which is exactly where a descriptor lives. Descriptors are the agent's whole product, so a rubric would land in Canvas structurally correct and completely empty, while appearing to succeed. Hard blocker; one-line fix in mcp-servers. - No update_rubric / delete_rubric, so the conversation can iterate right up to the write and not after it. - Prod runs a 7-tool build and lacks all four rubric OAuth scopes. Widening scopes also does not force re-consent: scopesHash is persisted but nothing reads it, so already-connected faculty keep an under-scoped vaulted token. That item lands in this repo rather than mcp-servers. - The cached tool snapshot is stale, so needsApproval cannot be set on create_rubric and the publish gate is prompt-enforced only. Includes ready-to-paste system instructions, both skill bodies, the KB outline, and a phased build order. Co-Authored-By: Claude Opus 5 * docs(specs): validate rubric agent auth path in dev; correct two claims Ran the scope change and the full rubric write path against dev (boisestatecanvas.test.instructure.com, course 50994). All four rubric OAuth scopes validated end to end: list_rubrics, create_rubric, list_assignments, associate_rubric. Added the run as a validation log (§10). Two corrections to the original spec: - Scope widening DOES force re-consent automatically. AgentCore's token vault keys on the requested scope set, so the first call after the scope edit returned "AUTHORIZATION NEEDED" rather than a Canvas 401. The scopesHash drift-detection work the spec called for is unnecessary, and that was the only blocker owned by this repo. What replaces it is a comms item: the prompt fires on the first call to any tool on the provider, not just one needing the new scope, so changing prod scopes reconnects every connected faculty member. - The long_description failure mode is worse than "descriptors go missing". Asked for named levels plus a descriptor per cell, the model overloaded the rating description field with the descriptor sentence and dropped the level names entirely, because the docstring offers ratings nowhere else to put one. The rubric renders with paragraphs where labels belong. Fixing the passthrough and the criteria schema together is what makes it correct; either alone still produces a wrong rubric. Two new findings, both from watching rather than reading: - §4.5 Attaching a use_for_grading rubric silently rewrote the assignment's points_possible from 5.0 to 8.0. Canvas behaviour, not our bug, but it is an unannounced grade-affecting edit to an assignment the instructor did not think they were touching. - §4.6 get_rubric returns an empty associations array for a live attachment, so the verification step in the publishing skill could not have worked as written. Verify via get_assignment_details instead. Co-Authored-By: Claude Opus 5 * docs(specs): gate create_rubric with a real approval interrupt Phil asked for the wizard to show the rubric details and for the create tool to be consent-gated. Both are now verified working in dev rather than planned. The spec previously settled for a prompt-enforced confirmation because the catalog snapshot was stale and needsApproval could not be set. That is fixed: "Discover from server" on the tool refreshed the cache from 7 to 42 tools (preserving existing approval flags), and create_rubric is now flagged needsApproval in dev. §4.4 goes from blocker to resolved. The gate is two layers on purpose. The markdown table is readable but is only the model's claim about what it will send; the approval card renders the exact tool_input, so it is the ground truth. Verified in both directions - approve proceeds, decline returns "User declined to approve the 'create_rubric' tool call" and writes nothing. Two behaviours from watching it, both folded into the system instructions: - Asked to "show the rubric as a table", the model reached for a charting tool and drew a bar chart of the point values before producing the table. The instruction now says markdown table written in your reply, and says not to visualize. - On decline, the model guessed the cause and suggested verifying permissions. A decline is the instructor's editorial decision, not a permissions failure, and the agent must not push users toward "fixing" a gate that is working. Also records the open question of whether associate_rubric needs its own gate, and the test rubrics left in course 50994 that need manual cleanup. Co-Authored-By: Claude Opus 5 * docs(specs): gate associate_rubric; add cutover, acceptance, residual risk Gates associate_rubric in dev alongside create_rubric. The extra prompt on the less-common path earns its keep: associate_rubric is the call that re-points the assignment, the one side effect in this feature that touches student-visible grades. That surfaced a limit of the gate worth writing down. The approval card renders tool_input - for associate_rubric that is course_id, rubric_id, assignment_id, use_for_grading. None of those four values tells the instructor that approving will change the assignment from 5 points to 8. The card shows inputs, not consequences, so the gate is necessary but not sufficient and the agent has to state the effect in the message before the prompt. Adds three sections the spec was missing: - 8.1 Acceptance criteria. Seven checks, led by the zero-question path, which is the headline claim of the whole design and the one most likely to quietly not hold. Includes the get_rubric descriptor round-trip as the single check that proves the 4.1 blocker fixed. - 8.2 Prod cutover checklist. Ten ordered steps across five owners. Steps 3 and 4 must land close together or rubric tools 401 with a message telling users a Canvas admin must act, which will already be done. - 8.3 Residual risk. The agent binds all 42 tools; two are gated and the other forty are held back only by the system prompt. That is a fence for ordinary use and no fence against a determined prompt. Names scoped bindings as the structural fix and what to do until then. Also promotes the prod student-role grant from an open question to a cutover blocker. It must be resolved before discovery caches 42 tools in prod, or the per-tool picker starts offering students grade_submission and create_assignment. Co-Authored-By: Claude Opus 5 * docs(specs): sync with mcp-servers#38; rubric scopes go from four to six The server-side fixes are built. Marks §4.1, §4.2, §4.5, §4.6 and §4.7 as addressed by Boise-State-Development/mcp-servers#38 and updates the cutover checklist to match what actually ships. The substantive change for anyone following the checklist: update_rubric and delete_rubric need PUT and DELETE on /courses/:course_id/rubrics/:id, so the Canvas developer key needs SIX rubric scopes, not four - and dev needs the two new ones too before those tools work there. tools/list becomes 44, discovery refreshes to 44, and delete_rubric joins the needs-approval set. Also updates the §6 token budget: 11.7k -> 13.2k for the two new tools and the structured criteria schema. Co-Authored-By: Claude Opus 5 * docs(specs): round-trip verified; record three findings from the run §8.1 criterion 4 now passes. A rubric created with descriptors and no assignment comes back from get_rubric with level names in description and descriptors in long_description, criterion points derived from the highest rating. That was the last unproven acceptance criterion. Worth recording why it passed: the typed schema alone changed the model's behaviour, twice, with different wording and no prompt guidance. Before #38 the same request packed descriptors into description and lost the level names. That is the argument for typed tool inputs over docstring instructions, and it belongs in the spec rather than in a PR description. Three findings from the run: - Two gates can stack on one tool call. A scope change and an approval gate both fired on the same create_rubric, so the user saw "Connect Canvas for Faculty" and "Approve create_rubric" at once with no indication of ordering. Someone who knows the system shrugs; a faculty member guesses wrong. - A scopes-only edit is impossible through the admin connector UI. The SPA sends oauthDiscoveryUrl on every save and the backend treats any non-None value as a discovery change, which requires a credential rotation. This blocks §8.2 step 4 for a prod connectors admin; worked around here with a direct PATCH. - Pre-#39 orphaned rubrics cannot be deleted either - Canvas answers 500, not 404 - so they need the Canvas UI. Two remain in course 50994. Co-Authored-By: Claude Opus 5 * docs(specs): agent built and smoke-tested in dev; name the KB gap Rubric Builder (ast-9149ef191614) is live in dev on Sonnet 5, bound to canvas_faculty plus both skills, with a five-document knowledge base (33 chunks). §8.1 criterion 1 passes. "Make a rubric for the Syllabus Acknowledgment assignment in my Canvas course" produced a complete draft table with zero questions: list_courses, list_assignments, get_assignment_details, activate rubric_authoring, draft. Points totalled to match the assignment and it stopped for approval before publishing. Two behaviours worth recording, because they distinguish a knowledge base that is read from one that is obeyed. It deviated from the guide with a stated reason - two criteria rather than the guide's three to six, because stretching it would have forced the compliance-flavored rows the guide warns against. And it named the outcome gap unprompted rather than quietly aligning to nothing. Also records what the knowledge base does NOT contain. It holds craft guidance only. Boise State program outcomes, the CTL's actual rubric guidance and house scale, and accreditation criteria are absent and were deliberately not invented: fabricating plausible substitutes would produce an agent that aligns rubrics to outcomes nobody adopted. The smoke test reached zero questions only because that assignment had no outcomes to align to. On a real assignment in a real program the outcomes slot will not fill and the agent will have to ask, which is exactly the §5 target it would miss. Supplying that content needs no engineering and is the highest-value item left. Co-Authored-By: Claude Opus 5 * docs(specs): all server fixes merged; orphan rubrics are unfixable, not UI-fixable mcp-servers#40 is merged, so the status line no longer lists it as open. Corrects the cleanup note. The spec said the two pre-#39 orphaned rubrics could be removed in the Canvas UI. They cannot be removed anywhere: DELETE 500s from a full Canvas admin browser session, not just through our OAuth token, and a rescue POST of a Course rubric_association 500s too under either purpose value. They are also absent from the Canvas UI entirely, under both Saved and Archived, so the UI was never a route to them. Removing them needs Instructure support or the test instance's monthly reset. Flags explicitly that the 500 is not an OAuth scope problem, since that is the obvious wrong trail for the next person: the same call fails identically for a Canvas admin with a valid CSRF token. Co-Authored-By: Claude Opus 5 * docs(specs): full §8.1 acceptance pass — all seven criteria green Ran the remaining acceptance criteria against the agent itself rather than plain chat. Nothing was written to Canvas; the single write attempt was declined deliberately to exercise criterion 6. Two criteria came out better than the spec asked for. Criterion 5 is the one that protects grades. Told to build a 20-point rubric for a 100-point assignment, the agent stopped before the approval gate and gave both numbers, the consequence, and an alternative - which matters because the approval card shows arguments and not effects. Criterion 6 confirmed the no-op state instead of merely accepting the decline: it said the assignment was still worth 100 points and no rubric was attached, then offered four revision directions. No retry, no permissions diagnosis. Criterion 2 produced direct evidence for the knowledge-base gap: the agent named it unprompted, saying it had no program outcomes list and asking to be pointed at them. That is the §5 zero-question target degrading exactly where predicted. Also worth recording: the agent follows the publishing skill without being told to, calling list_rubrics before drafting as the skill's Before writing section instructs. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 * feat(frontend): drill into tool detail from the tools drawer The drawer expanded an MCP server's per-tool toggles inline, into the same 320px column — Student MyBoiseState pushed 17 nested rows into a space narrower than a phone, and there was nowhere to put anything a row cannot hold. Prod is now 31 catalog entries expanding to ~128 callable tools. The list and a new detail pane now sit side by side in one clipped stack and slide as a pair. The list keeps its scroll position underneath, and the detail gets the drawer's full width. Rows become the split row already shipped in agent-listing-row: the body opens the detail, the switch is its sibling. Three rules come with that pattern, each already paid for in that component: * Siblings, never nested — a