Skip to content

Backmerge: main into develop (1.6.1)#664

Merged
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.6.1
Jul 16, 2026
Merged

Backmerge: main into develop (1.6.1)#664
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.6.1

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Reconcile develop with main after the 1.6.1 release (#663) squash-merged into main.

This is the required post-release backmerge. It brings the 1.6.1 release-artifact state (VERSION, CHANGELOG, RELEASE_NOTES, README, synced manifests + lockfiles) back onto develop and makes main a genuine ancestor of develop again, so the next release doesn't conflict on the release files.

Clean auto-merge — no conflicts. sync-version.sh --check PASSes at 1.6.1.

⚠️ Merge this with a MERGE COMMIT, not a squash. Squashing recreates the divergence and the conflicts return next release.

🤖 Generated with Claude Code

philmerrell and others added 2 commits July 16, 2026 17:05
* feat: persist interrupted-turn context so aborted responses aren't orphaned

When a response is interrupted (user Stop, refresh, or dropped socket) the
user turn was already persisted but the assistant reply was not, leaving a
dangling user turn — no context on reload and malformed history the model
must paper over.

Backend:
- set/clear_interrupted_turn markers (metadata.py) with race-safe reason
  precedence: user_stopped (authoritative client signal) always wins over
  the connection_lost cancellation fallback. clear is an atomic pop
  (ReturnValues=UPDATED_OLD) returning the settled reason.
- stream_coordinator: (CancelledError, GeneratorExit) backstop persists the
  IN-FLIGHT partial assistant text (accumulator scoped to the current
  message so already-persisted mid-turn messages aren't duplicated) via
  asyncio.shield; empty-partial placeholder gated on a user-tail to repair
  role alternation only where needed.
- app-api POST /sessions/{id}/interrupt: authoritative user_stopped carrier
  (cookie auth; fetch keepalive + X-CSRF-Token, not sendBeacon). Lives on
  app-api because the AgentCore Runtime data plane only proxies
  /invocations + /ping.
- inference-api: pop the marker at turn start and prepend a reason-specific
  ephemeral interruption note at the pending_ctx_block seam (invisible to
  the user via the displayText split; ages out via compaction).

Remaining: SPA stop signal in cancelChatRequest + reload chip/Continue.

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

* feat(spa): surface interrupted-turn state — stop signal + reload chip

Completes the interrupted-turn feature on the SPA side:

- cancelChatRequest fires a best-effort keepalive fetch to
  POST /sessions/{id}/interrupt with {reason:'user_stopped'} + X-CSRF-Token
  (not sendBeacon — it can't set the CSRF header) before aborting, and
  reflects the interruption locally so the chip shows without a reload.
- lastTurnInterrupted + reason are per-session ChatState signals (mirroring
  lastTurnContinuable): hydrated from session metadata on reload (set-true
  only), cleared beside every continuable clear (new send / continuation /
  new streamed turn).
- message-actions renders a chip on the last message: connection_lost →
  "Response interrupted" + Continue (reuses continueTruncatedTurn against
  the persisted partial); user_stopped → "You stopped this response", no
  Continue. Gated on last-message + not-loading; max_tokens Continue wins.

Specs cover both chip variants, Continue emit + precedence, the keepalive
signal shape, local reflect, failure isolation, and continuation clears.
Full app build + affected unit specs green.

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

* docs: add assistant KB sync (scheduled re-index) design spec

Sweeper-not-scheduler architecture with layered runaway guards; all five
open questions resolved against AgentCore Identity docs, Drive API docs,
and code inspection.

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

* feat(kb-sync): add SyncPolicy data layer, DueSyncIndex GSI, delete cascades

PR-1 of the KB sync feature (docs/specs/assistant-kb-sync.md):
- SyncPolicy model + repository in apis.shared.sync_policies (adjacency
  list SYNCPOL# items on the assistants table)
- Sparse DueSyncIndex (GSI4) — keys present only while state=active, so
  paused policies are physically invisible to the dispatcher sweep
- Conditional re-arm for idempotent dispatch; breaker counters
  (consecutive failures / source-gone) maintained by record_sync_result
- Assistant and document delete paths eagerly cascade sync policies so
  no schedule outlives its source
- Document gains contentHash/lastSyncedAt/syncPolicyId; Assistant gains
  lastUsedAt (inactivity-pause input)

Inert until the PR-2 dispatcher exists: nothing reads DueSyncIndex yet.

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

* feat(kb-sync): dispatcher + worker Lambdas, EventBridge sweep, kb-sync image pipeline

PR-2 of the KB sync feature (docs/specs/assistant-kb-sync.md):

Backend:
- kb-sync dispatcher (apis/app_api/kb_sync/dispatcher.py) — sweeps the
  sparse DueSyncIndex each tick and applies the runaway guards in
  order: kill switch, liveness (orphan policies hard-deleted),
  circuit breaker (failure/not-found streaks -> paused_error),
  30-day inactivity pause, in-flight skip via syncRunStartedAt,
  re-arm-with-backoff BEFORE async-invoking the worker
- worker stub (records "skipped", clears the run stamp); Drive sync
  lands in PR-3, web re-crawl in PR-4
- rearm_policy gains mark_run_started so claiming the in-flight slot
  is atomic with winning the re-arm
- dispatcher reads assistant/source records by raw adjacency-list key
  (keeps the image surface to apis.shared.sync_policies + kb_sync);
  tests create them through the real services so schema drift breaks

Infra:
- KbSyncConstruct: two DockerImageFunctions sharing one image with
  ImageConfig.Command overrides; rate(15 min) EventBridge rule —
  DISABLED unless CDK_KB_SYNC_ENABLED=true (dark by default), env
  kill switch mirrors the flag; namespace-conditioned PutMetricData;
  error alarms; function-name SSM params for the code-deploy step
- platform-as-bootstrap: byte-stable bootstrap-assets/kb-sync stub

Pipeline:
- backend/Dockerfile.kb-sync (lightweight: boto3+pydantic, no ML deps)
- build-one.sh kb-sync case, deploy-image-lambda-one.sh
  kb-sync-dispatcher/worker cases sharing one image tag
- deploy-image-lambda-one.sh: first-deploy grace skip when the
  function-name SSM param doesn't exist yet (backend.yml runs before
  the platform deploy on the introducing PR)
- backend.yml build+deploy jobs; nightly scan-images + supply-chain
  test lists include the new Dockerfile

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

* feat(kb-sync): Drive-file sync path — vault token, change detection, staged re-ingest

PR-3 of the KB sync feature (docs/specs/assistant-kb-sync.md §6.1):

Worker (replaces PR-2 stub for drive_file policies):
- resolves the policy creator's stored Google token from the AgentCore
  Identity vault (GetWorkloadAccessTokenForUserId -> GetResourceOauth2Token,
  no live user session; same customParameters as consent — vault-key rule)
- two-gate change detection: Drive files.get version vs stored sourceEtag
  (continuous with import provenance), then sha256 vs contentHash —
  identical bytes never reach Docling/Titan
- changed bytes staged to the document's EXISTING S3 key -> the untouched
  ingestion pipeline re-chunks/re-embeds via its S3 event
- pause semantics per spec §7: requires_consent / Google 401 ->
  paused_reauth (not a failure streak); Drive 404 (deleted-or-unshared,
  indistinguishable) -> not_found strike, dispatcher pauses at 2;
  trashed=true -> grace skip; provider/adapter/provenance gone ->
  paused_error; every path records the run so the stamp always clears

Shrinkage cleanup:
- worker stashes previousChunkCount before staging; ingestion completion
  atomically pops it (REMOVE + UPDATED_OLD — duplicate S3 events can't
  double-delete) and deletes stale tail vectors {doc}#{new..prev-1} via
  new delete_vector_tail; uses post-split len(chunks), the true vector
  count this run wrote

Adapter/image/infra:
- GoogleDriveAdapter.get_file_metadata (cheap files.get, trashed +
  version/md5Checksum/modifiedTime fields)
- kb-sync image gains apis.shared.oauth + file_sources (FastAPI-free,
  verified by import-surface simulation); httpx + bedrock-agentcore pins
- worker IAM: vault token read (two Get* actions only — no consent
  completion, no provider CRUD), oauth-providers table read, documents
  bucket put; env: workload identity name + callback URL (same values
  app-api uses)

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

* feat(kb-sync): web re-crawl path — refresh/upsert crawls, miss accounting, TTL fix

PR-4 of the KB sync feature (docs/specs/assistant-kb-sync.md §6.2):

Crawler refresh mode (opt-in via RefreshState; normal crawls unchanged):
- upsert-by-URL: known pages reuse their document records — a re-crawl
  never duplicates documents
- conditional GET with the stored ETag (304 = no body, no re-extract)
  plus a content-hash gate; identical bytes never re-embed
- 'changed' emitted BEFORE the S3 overwrite so the worker stashes the
  previous chunk count ahead of the ingestion event (shrinkage cleanup)
- transient fetch errors never flip an existing indexed doc to failed;
  its last-good content keeps serving
- link-enqueue block deduplicated into a nested helper

CrawlJob TTL fix (the silent-kill trap): terminal crawl jobs carry a
30-day TTL — a sync-covered job auto-expiring would trip the
dispatcher's liveness check and delete the policy. finalize_crawl gains
set_ttl; sync re-crawls finalize with the ttl REMOVED, and
reset_crawl_for_refresh rearms the job (running, zeroed counters, no
ttl) before each run.

Worker web path:
- re-runs the policy's crawl with its stored (already-capped) settings,
  13-min crawler budget inside the 15-min Lambda so finalize + miss
  accounting always complete
- miss accounting: pages absent from 2 CONSECUTIVE re-crawls are
  soft-deleted with cleanup run inline; fetch failures count as seen
  (outage != gone), robots-disallowed pages do not; misses reset on
  reappearance; missing root doc is recreated route-style

Image: web_sources + documents + embeddings trees; beautifulsoup4,
trafilatura, lxml pins. Worker Lambda timeout 10 -> 15 min.

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

* feat(kb-sync): sync-policy API + resume hooks — CRUD routes, run-now, reauth/inactivity resume

- New /assistants/{id}/sync-policies router (edit-gated like documents):
  create/list/patch/delete + POST run-now (202, atomic 10-min cooldown on
  lastManualRunAt). Resume of paused_reauth is 409 — only a fresh OAuth
  consent resumes it.
- Reauth markers: worker pause writes USER#/SYNCREAUTH# marker so
  complete_consent() resumes exactly the right policies with one query;
  markers are advisory (resume re-verifies state) and self-clean.
- Inactivity resume: throttled lastUsedAt bump on chat use (conditional
  write, one winner/day) wakes paused_inactive policies due-immediately.
- restore_crawl_ttl: deleting a crawl's policy puts the job back on
  normal 30-day expiry (running jobs untouched).
- drive_file policies keep a syncPolicyId back-pointer on the document,
  cleared on policy delete.

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

* feat(kb-sync): SPA sync-policy controls on assistant knowledge page

PR-6 (final) of the KB-sync series. Per-source "Keep in sync" controls
on the assistant knowledge editor for imported Drive documents and web
crawls: interval select (Manual only/Daily/Weekly/Monthly), status line
(state, reason, last/next sync), pause/resume, Sync now (cooldown-aware),
and a Reconnect affordance for paused_reauth policies that routes through
the existing OAuth consent popup (a fresh consent auto-resumes server-side).
Controls are owner/editor-only; device uploads show no control.

Backend (additive, same-PR per cross-package contract):
- DocumentResponse now exposes sourceConnectorId/sourceAdapterKey/
  sourceFileId/syncPolicyId/lastSyncedAt so the SPA can tell imported
  docs from device uploads and route reconnect
- GET /web-sources/crawls without ?active=true returns full history
  (list_all_crawls) — completed crawls are the syncable web sources

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

* feat(interrupted-turn): persist stopped-turn metadata so cost + message badges survive

A Stop aborts the fetch before the stream's terminal `metadata` SSE (usage /
cost / context) can arrive, so both the per-message metadata badges and the
session cost badge stayed blank for that turn — and blank even after reload,
because the cancellation path persisted only the partial text + interrupted
marker, never any metadata.

The abort is load-bearing (killing the socket is what stops generation and
billing), so the backend can't push the metadata to the dead socket. Instead:

- Backend: `_persist_interruption` now also calls `_store_message_metadata`
  (the same call the `done` path uses), which writes the per-message row AND
  bumps the denormalized session aggregates that hydrate the cost badge on
  reload. Keyed to the interrupted message's odd-position index. A cut
  generation often never delivered Bedrock's terminal usage event, so new
  `_projected_input_usage` falls back to the context-attribution projection
  for input-side tokens/cost (output unknown → priced at zero).
- Frontend: `refreshAggregatesAfterStop` (delayed + one retry, mirroring
  refreshTitleFromServer, since the write races the abort) re-pulls session
  metadata and re-seeds the cost badge live. Per-message badges hydrate from
  the same persisted row on the next reload.

Known limitation: the per-message contextBreakdown partition badge is not a
persisted field (live-only even for normal turns), so it stays blank on a
stopped/reloaded turn — a separate change affecting all turns.

Tests: backend test_interrupted_turn_persistence.py (+2) and full streaming
dir (175) green; frontend chat-http.service.spec.ts (+2) green.

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

* fix(kb-sync): grant worker read on the vault's backing OAuth secrets

The KB sync worker resolves the policy creator's Google token from the
AgentCore Identity vault via GetResourceOauth2Token, but that call reads
the refresh token *through* the Secrets Manager secret the vault
auto-creates per provider (bedrock-agentcore-identity!default/oauth2/<id>).
The worker role granted only the two bedrock-agentcore:* actions, so the
call failed with AccessDenied on secretsmanager:GetSecretValue and every
Drive sync returned "failed".

Add the read-only AgentCoreIdentityOAuthSecrets grant (GetSecretValue +
DescribeSecret on ...!default/oauth2/*) — the same grant app-api and
inference-api already carry, minus the write lifecycle a background
fetcher never needs. Covered by a new assertion in kb-sync.test.ts.

Verified live in dev-ai: with the grant, the worker gets past the vault
read (the remaining paused_reauth is a genuine expired-refresh-token,
not this IAM gap).

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

* refactor(oauth): forward admin customParameters, drop hardcoded vendor baseline

`custom_parameters_for` previously injected vendor-specific OAuth params
(Google `access_type=offline`, plus `prompt=consent` on the force-auth
path) on top of the connector's admin-configured extras, keyed off
`provider_type`. That hid a documented requirement inside the code and
made the `customParameters` map depend on the call site (grant vs
retrieval), which AgentCore factors into its vault key.

Simplify to a single rule: every call site forwards the connector's
admin-configured `customParameters` verbatim via
`custom_parameters_for(admin_extras)`. Admins set `access_type=offline`
and `prompt=consent` in the connector's "Custom OAuth Parameters" field
(the seeded Google connector already carries both), so the same map is
sent on consent and on every retrieval — no baseline merge, no
`provider_type`/`force_authentication` branching, no per-call-site drift.

Removes `_vendor_baseline_params`, the `provider_type_lookup` plumbing on
the consent hook, and the `force_authentication=True` customParameters
argument at all read sites (the SDK-level `force_authentication` flag is
unchanged). Behaviour for the existing Google connector is identical
(same resulting map); the win is that the vault key is now provably
consistent and there are no hidden customizations in the token path.

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

* docs: add tool-search token-bloat + per-user markdown memory specs

Two design specs drafted alongside recent exploration work:
- tool-search-token-bloat-strategy: cross-source tool-search plan for
  MCP token bloat (tiered discovery, AWS Agent Registry tier).
- user-markdown-memory: per-user markdown "second brain" memory,
  re-scoped skills reference-file mechanism, prompt-cache injection.

Docs only; no code or behaviour change.

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

* feat(kb-sync): log workload identity + userId on a reauth pause

A reauth pause was opaque — "credentials need re-consent" gave no hint
why. The vault token is keyed by (workload identity, userId), so the
overwhelmingly common cause is that the token was vaulted under a
DIFFERENT workload identity than the worker queries: e.g. a consent done
through local dev (AGENTCORE_RUNTIME_WORKLOAD_NAME=local_dev_inference)
can't be read by the deployed worker (platform-workload), and vice
versa. That exact mismatch cost hours to diagnose.

Surface the workload name, userId, and provider the lookup used in the
pause warning so the mismatch is obvious in one log line.

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

* chore(kb-sync): plumb CDK_KB_SYNC_ENABLED into the platform deploy

KB sync ships dark: the EventBridge rule is created disabled and the
KB_SYNC_ENABLED kill switch is false unless CDK synths with
CDK_KB_SYNC_ENABLED=true (config.ts). platform.yml never forwarded that
var, so a CDK deploy always synthed the feature off — the only way to
enable it was an out-of-band CLI flip that reverts on the next deploy.

Forward it as an environment-scoped `${{ vars.CDK_KB_SYNC_ENABLED }}`
like every other CDK_ var. The development environment variable is set
to "true" (enables the rule + kill switch in dev-ai durably); production
leaves it unset, so it stays dark until validated there.

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

* chore(kb-sync): default the feature ON with a kill switch

KB sync shipped opt-in (default off), a holdover from ship-dark
incremental development. The feature is complete and guarded, so a
deployer/cloner of the public stack should get it working by default —
a hidden default-off flag is bad DX. Invert to opt-out: enabled unless
CDK_KB_SYNC_ENABLED=false (or a `kbSync.enabled: false` cdk.json context).

The workflow forwards `${{ vars.CDK_KB_SYNC_ENABLED }}`, which is an empty
string when unset — a plain `?? false` fallback wouldn't flip the default,
so config.ts treats empty/unset as "use the default (on)" and only the
literal "false" as the kill switch. Adds config-resolution tests covering
unset / empty / "true" / "false" / context-disable.

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

* feat(kb-sync): clarify the auto-sync control and always show last-synced

The knowledge-base sync control was a bare "Manual only" dropdown that
never explained what syncing does, and its status line only appeared
while a schedule was active — a manual-only file showed nothing.

- Lead the control with an "Auto-sync from <source>" label (with a
  descriptive title tooltip) so every row states what it is and where it
  pulls from.
- Rename the options to self-describing verbs: "Don't auto-sync",
  "Sync daily/weekly/monthly".
- Always surface a "Last synced" line, including manual-only sources, via
  a new lastSyncedAt input fed from Document.lastSyncedAt.
- Pair a relative age with an absolute date/time ("Synced 2h ago ·
  Jul 3, 2:14 PM") for both "how long ago" and "exactly when".

Display-only; the timestamps already exist in the data model.

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

* fix(kb-sync): tidy the sync row layout and repair blank sync timestamps

Aligns the knowledge-base sync control with the approved mockup and fixes
a bug that rendered the status line as a bare "Synced · next sync".

Layout:
- Move the "Auto-sync from <source>" context out of the control's button
  row (where it forced "Pause" to wrap) and into the file's meta line.
- Compact the control to just the schedule select + actions.
- Add a status-line dot (green healthy / amber attention / grey idle).
- Top-align the status icon and download/trash actions with the filename
  (items-start) instead of floating against the taller row.

Timestamp bug: several generators built ISO strings as
`datetime.isoformat() + "Z"`, yielding "…+00:00Z" (offset AND Z). That is
invalid ISO 8601 and parses to Invalid Date in strict engines (Safari),
so last-sync / next-sync rendered blank. Normalize to a single trailing Z
in service, dispatcher, and worker; harden the dispatcher parser to always
return a UTC-aware datetime; and harden the SPA to tolerate the legacy
"+00:00Z" so already-persisted policies still render.

Download and delete controls on documents are preserved.

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

* fix(kb-sync): give web sources the same sync treatment as documents

The web-source (crawl) row shared the sync control with documents but was
missing the parity bindings the document row got: no source-context line
and no persistent last-synced timestamp, so a manual web source rendered
as a bare dropdown while an identical document showed full context.

- Add the meta-line context "Auto-sync from the web" (guarded by
  isCrawlSyncable), mirroring the document's "Auto-sync from <provider>".
- Feed the control lastSyncedAt from crawl.completedAt so a manual web
  source still shows when it last refreshed (the component already prefers
  the policy's lastSyncAt when a schedule is active).
- Make the crawl meta line wrap-safe (flex-wrap), matching the doc row.

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

* feat(kb-sync): unified skeleton loading for the knowledge base lists

The Web sources list had no loading flag, so it popped into existence
after its network round-trip, while Uploaded Documents showed a lone
centered spinner — two async lists with inconsistent, jarring loads.

Introduce one initial-load gate for both lists:
- Add isLoadingCrawls, set from loadSyncData (cleared on the viewer
  early-return and in finally), mirroring isLoadingDocuments.
- isLoadingKnowledge computed gates both lists so they reveal together;
  raise both flags synchronously in ngOnInit so the first paint is the
  skeleton, not an empty flash.
- Replace the documents spinner with a skeleton list (pulsing icon + two
  text bars, varied widths) that mirrors the final row shape — no layout
  shift, and consistent with the existing connector-button skeleton.

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

* feat(kb-sync): show a "Saving…" indicator while a sync change applies

Changing the schedule (e.g. Don't auto-sync → Sync weekly) held the busy
state for the whole create/update/delete round-trip but only disabled the
controls — there was no positive sign anything was happening, and the
select eagerly reverts to its old value until the mutation confirms, so it
read as "nothing happened".

While busy, the status slot now shows a spinner + "Saving…" (role=status),
taking precedence over the sync status line — including for a manual-only
source being enabled, where no policy or status exists yet.

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

* fix(users): normalize sync timestamps to strict ISO 8601 (single Z)

UserSyncService built timestamps as `datetime.isoformat() + "Z"`, yielding
"…+00:00Z" — both an offset AND a Z. That is invalid ISO 8601 and parses to
Invalid Date in strict engines (Safari), so the admin user-list "Last login"
and user-detail "Created"/"Last login" dates (rendered via `new Date()`)
showed "Never".

Same class of bug just fixed across KB-sync (service/dispatcher/worker);
this applies the identical normalization to the users domain.

- Write path: add `_iso()` helper, normalize `created_at`/`last_login_at`.
- Read path: add `_heal_iso()` and apply in `_item_to_profile` /
  `_item_to_list_item` so legacy "+00:00Z" rows render correctly. Necessary
  because `created_at` is preserved across logins forever (never rewritten),
  so pre-fix users would otherwise show "Never" in Safari permanently.
- Regression tests for the write-path invariant and read-path heal.

`last_login_at` also backs GSI2SK/GSI3SK (sort-by-last-login); the suffix
change is lexicographically safe — ordering is dominated by the microsecond
datetime prefix and a transient mix of "+00:00Z"/"Z" rows does not corrupt it.

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

* feat(assistant-editor): group knowledge base into a contrasted inset panel

Wrap the Knowledge base section on the Assistant editor in a rounded,
bordered inset with a subtle gray-100/60 fill instead of the flat
border-t divider shared by other sections. Against the gray-50 form
column this reads as a mildly contrasted group, and the white inner
lists (Web sources / Uploaded Documents) now float on the tint to
reinforce the grouping. Drops the border-t/pt-8 divider since the card
provides its own separation; the form's space-y-8 preserves the top gap.

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

* feat(connectors): add Google connector logo SVGs

Add Drive, Docs, Gmail, and Calendar icon assets under public/logos.

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

* docs(specs): agentic platform primitives plan + scheduled-runs design + spike brief

Reframe the proactive-agent effort from a single "Oliver" feature into a
primitive-enablement plan, mapped onto the Harness (headless run entrypoint)
and Registry (catalog + governance) explorations.

- agentic-platform-primitives.md: primitive maturity + gap ledger, the six
  fundamentals (F1 headless run entrypoint .. F6 registry/governance),
  phased plan, and Harness/Registry overlap. Oliver demoted to one
  validation use case among several.
- scheduled-agent-runs.md: detailed design for F1+F2+F3 (renamed from the
  earlier Oliver draft; generalized so any config/prompt/cadence works).
- harness-entrypoint-spike-brief.md: tight spike brief for the F1 keystone
  (unattended-as-user auth + server-side SSE), to run in dev-ai.

Resolved decisions: F1 = minimal internal run_agent_headless(), A2A-ready;
governance floor (F6a) pulled forward into Phase A, Registry discovery (F6b)
deferred. Open: KB decoupling appetite, floor depth, sequencing.

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

* feat(harness): spike headless agent-run entrypoint (F1) — proven in dev-ai

run_agent_headless in apis/shared/harness: per-owner Cognito bearer mint
(workload-token + SigV4 front-door paths proven dead at the runtime
gateway), server-side SSE drain pinned to live wire shapes, F6a audit
records + guardrails/classification seams, delivery via the runtime's own
session materialization + title override. Driver script reproduces the
dev-ai proof and the negative auth probes. Findings + Phase A design in
docs/specs/harness-entrypoint-spike-findings.md.

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

* docs(specs): record F1 spike findings + lock decision-gate calls

The headless-run entrypoint spike (Fable 5) is GO — proven end-to-end in
dev-ai. Bring its findings doc onto develop as the decision record and lock
the four gate decisions into the plan of record.

- Add harness-entrypoint-spike-findings.md (design deliverables + Phase A
  punch list; full spike code lives on branch spike/harness-headless-entrypoint).
- agentic-platform-primitives.md §6: resolve act-as-user auth policy (Cognito
  per-owner token behind an explicit headless-grant record), F6a floor depth
  (audit fail-closed now; PII checkpoint required before unattended schedules),
  KB decoupling (defer), sequencing (proactive-spine-first confirmed).
- scheduled-agent-runs.md §8: mark unattended-auth resolved with the chosen path.

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

* feat(harness): headless-grant record + production hardening of the F1 primitive

Replace the spike's BFF-table Scan with an explicit headless-grant record
(apis/shared/harness/grants.py): create-on-enable from an attended session,
per-owner lookup via the sparse HeadlessGrantUserIndex GSI, revocation that
deletes the stored credential, and a documented "must have logged in within
30 days" policy (TTL anchored to the login that issued the pinned refresh
token, matching the Cognito refresh-token validity).

CognitoRefreshBearerAuth now mints from the grant (rotation-aware: a rotated
refresh token is persisted back before the mint returns — punch-list #5).
Dedupe build_invocations_url into the harness as the single canonical
resolver; the chat proxy imports it (punch-list #4). Document the
enabled_tools=None = all-RBAC-allowed semantic and the schedule-snapshot
rule (punch-list #7). Governance docstrings updated to the locked F6a
decision: audit-only fail-closed + wired no-op guardrail/classification
seams, implementations gated to the scheduled phase. Add an
_build_http_client seam and tests covering the grant lifecycle,
grant-backed minting, audit fail-closed ordering, and stream outcomes
against a MockTransport runtime.

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

* feat(runs): cookie-authed "Run now" surface behind flag + RBAC capability

POST /runs/now executes one agent turn through the exact unattended path a
scheduled run will use (create-on-enable grant -> per-owner Cognito mint ->
runtime /invocations -> server-side SSE drain -> governance floor -> session
materialization) — the PR-1 validation surface from
docs/specs/scheduled-agent-runs.md §7. GET/DELETE /runs/grant expose grant
status and total revocation.

Gating is two independent controls (spec §6): the SCHEDULED_RUNS_ENABLED
kill switch (default ON; only the literal "false" disables — empty workflow
vars can't dark-stop prod) and a new `scheduled-runs` RBAC capability
resolved through the mature tools grant axis
(apis/shared/rbac/capabilities.py) — GA = grant the id to the default role.
Auth is the standard SPA cookie dependency per the CLAUDE.md app-api rule;
mint failures surface as 409, never 401, so the SPA is not bounced through
the login redirect.

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

* feat(infra): SCHEDULED_RUNS_ENABLED flag + HeadlessGrantUserIndex GSI

Thread the scheduled-runs kill switch through CDK: scheduledRuns.enabled in
config.ts (the CDK_KB_SYNC_ENABLED empty-string-safe ternary, copied
exactly) -> SCHEDULED_RUNS_ENABLED on the app-api container ->
CDK_SCHEDULED_RUNS_ENABLED forwarded by platform.yml. Default ON with a
kill switch; nightly inherits the default.

Add the sparse HeadlessGrantUserIndex GSI (grant_user_id / created_at) to
the BFF sessions table backing apis/shared/harness/grants.py — only
HEADLESS-GRANT# items carry the partition attribute, so session rows never
project into it. App-api's existing table grant already covers index/*, so
no IAM change. Tests mirror the kbSync flag matrix and pin the GSI shape.

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

* docs(specs): Phase B scoping brief — scheduler + PII-ordering prerequisite

Records the work breakdown, model tiering, and the one design fork gating
scheduled delivery: the F6a classify_output checkpoint must run in-loop (or
as a post-hoc scrub), not pre-delivery, because the runtime turn persists
the session during the turn. B1 (inert schedule CRUD) is safe to start now.

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

* docs(specs): governance for scheduled runs is role/auth-based; drop B0 PII gate

A headless run executes as the owner with the owner's RBAC and delivers
only to the owner's own session list, so it crosses no new access boundary
and introduces no new recipient. Governance = RBAC (run-as-user) + grant
lifecycle + quota + fail-closed audit — all already built. The content
classification pass adds nothing for deliver-to-self, so B0 collapses and
B2 delivery is unblocked. Revises primitives §6-4 and the Phase B brief §1.

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

* feat(schedules): B1 — schedule data model + CRUD (inert)

Add ScheduledPrompt model + ScheduledPromptService (apis/shared, mirroring
sync_policies) and app-api CRUD under /schedules — create/list/get/update/
pause/resume/delete, gated by SCHEDULED_RUNS_ENABLED + the scheduled-runs
RBAC capability. Cadence (daily/weekday/weekly) -> next_run_at is computed
timezone-aware in the service so the future dispatcher stays a dumb "who's
due" query. enabled_tools is snapshotted at creation (Phase A punch #7).

Storage rides the existing sessions-metadata table (PK=USER#{user_id},
SK=SCHEDPROMPT#{schedule_id}), with a new sparse DueScheduleIndex GSI
(GSI3_PK/GSI3_SK, distinct from SessionLookupIndex's GSI_PK/GSI_SK to avoid
attribute collision) projected only while state=="active". app-api already
holds CRUD+index/* IAM grants on this table, so no IAM changes were needed.

Deliberately inert: nothing fires yet. The dispatcher/worker that reads
DueScheduleIndex and calls run_agent_headless is B2.

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

* feat(schedules): B3 — schedule management SPA + enablement

Adds the user-facing schedules feature: a signal-based list/create/edit
page under frontend/ai.client/src/app/schedules/ (bounded cadence UI —
daily/weekday/weekly + hour + IANA timezone, optional assistant + tool
snapshot, pause/resume/delete with confirm), the headless-grant
enablement UX (status banner, "Enable scheduled runs", paused_error
reauth_required / oauth_required affordances), and capability-gated nav
visibility that rides the /schedules list call's 403/404 rather than a
dedicated client signal.

Backend: adds POST /runs/grant to apis/app_api/runs/routes.py, sharing
the existing _resolve_grant create-on-enable logic with /runs/now so a
user can turn on scheduled runs without running a prompt first. Same
kill-switch + scheduled-runs capability gating.

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

* feat(schedules): B2 — scheduler dispatcher + worker

EventBridge rate(5m) -> dispatcher (sweeps the sparse DueScheduleIndex,
runaway guard, conditional re-arm before fire-and-forget) -> worker (mints a
per-owner Cognito bearer, run_agent_headless(trigger="schedule"), records the
outcome, pauses on reauth_required / oauth_required / repeated_failures).
Two Docker Lambdas share one image (Dockerfile.scheduled-runs) via
ImageConfig.Command; bootstrap-stub + out-of-band image deploy mirroring
kb-sync. IAM scoped to sessions-metadata RW, BFF-grant table RW, app-client
secret read, and AgentCore vault token + oauth-secret read; no
SigV4/InvokeAgentRuntime (the minted bearer is the front door, matching the
Run-now path). Delivery flows straight through — governance is role/auth-based.

Fixes the failure breaker: added a persistent consecutive_failures counter
(model + record_run_result, mirroring sync_policies) so repeated_failures
pauses at the production default of 3. The original last-status proxy capped
the streak at 2 and could never trip at the default; unified the worker's two
error paths on the returned streak and added regression tests at the default
threshold.

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

* fix(schedules): register nav routes in specs to fix CI NG04002

The schedule-form and schedules-list specs used provideRouter([]), but the
components navigate programmatically (router.navigate(['/schedules']) etc.)
on save/cancel/create/edit. With no matching route the navigation rejects
(NG04002) AFTER the test body, surfacing as unhandled rejections that fail
the whole vitest process even though all 1378 tests pass. Register the
target routes so the real router resolves them. Scoped ng test: 46 passed,
0 errors.

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

* fix(schedules): make scheduled-runs worker image importable

The worker Lambda crashed at INIT with ImportModuleError: the lean
scheduled-runs image (Dockerfile.scheduled-runs) never installed
cryptography or cachetools, which the worker pulls in transitively via
apis.shared.harness -> apis.shared.sessions_bff (cookie.py AESGCM,
cache.py TTLCache). Even past that, apis/shared/sessions/metadata.py
imported agents.main_agent...is_preview_session at module top level,
dragging the agents+strands packages (deliberately absent from the lean
image) into the worker's runtime delivery path.

- Defer the preview-session import in sessions/metadata.py to call time.
  The two functions the headless delivery path uses
  (ensure_session_metadata_exists / update_session_title) never call it,
  so the agents import is never triggered in a headless run. Public name
  and behavior unchanged for live app_api/inference_api callers.
- Add cryptography==48.0.1 + cachetools==6.2.4 to the shared image
  requirements (dispatcher requirements.txt is the file the Dockerfile
  installs; worker kept in sync).

Verified: worker + dispatcher import cleanly in an isolated lean-image
simulation (image pins + bundled modules only, no agents/strands).
Surfaced by dogfooding a real scheduled run in dev-ai.

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

* fix(schedules): make preview-session check importable in lean worker image

Follow-up to the worker-image import fix (#566). Dogfooding the deployed
worker surfaced a second, deferred failure: the headless delivery path
(runner -> ensure_session_metadata_exists, metadata.py:773) DOES call
is_preview_session, so the previous lazy-import wrapper only moved the
ModuleNotFoundError: No module named 'agents' from INIT to run time. The
run still 'completed' (the runtime materializes the session during the
turn), but the idempotent session-row ensure + title override were
skipped ('result delivery failed' in the worker log).

is_preview_session is a trivial startswith('preview-') check; its weight
came only from agents/strands imports in
agents.main_agent.session.preview_session_manager. Move a dependency-free
copy into apis.shared.sessions.preview (mirroring what
apis.inference_api.chat.routes already does with its own local copy) and
import it in metadata.py. A drift-guard test keeps the literal in lockstep
with agents...Prefixes.PREVIEW_SESSION.

Verified: lean-image simulation now imports metadata + runs
is_preview_session with no agents/strands; 77 targeted tests pass.

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

* fix(scheduled-runs): intersect client enabled_tools with RBAC on headless paths

Client-supplied `enabled_tools` was trusted end to end on the headless
surfaces: schedule create/update and "Run now" froze/forwarded the request
body verbatim, and inference-api's tool filter performs no RBAC check of its
own (registry membership is its only gate). A user holding the scheduled-runs
capability could therefore craft a request enabling a tool outside their
AppRole, and — for schedules — persist it into an unattended, repeating run.

Add `AppRoleService.filter_requested_tools`, a narrow-never-grant intersection
of a requested tool list against the caller's resolved RBAC grant (honors the
`*` wildcard; admits a scoped `base::tool` id when its base server is granted),
mirroring the existing `_apply_enabled_skills_filter` contract on the skills
axis. Apply it at the three app-api write/entry points where a full User (with
roles) is in hand: schedule create, schedule update (PATCH must not bypass the
create-time check), and run-now. `None` is preserved as "resolve to defaults".

Note: fire-time re-intersection against *current* RBAC (so a later role
revocation disarms a sleeping schedule) is deferred — the worker/dispatcher
only carry a bare user_id, not the owner's live roles. Tracked as follow-up.

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

* test(schedules): guard the scheduled-runs lean image against agents/strands imports

Add an AST-based test asserting the modules bundled into
backend/Dockerfile.scheduled-runs (harness/, scheduled_prompts/,
sessions_bff/, sessions/ + the two lambda handlers) never import agents/
or strands — top level OR lazy, since a deferred import still crashes at
call time in the lean image. This is the guard that would have caught the
ModuleNotFoundError shipped in the first cut of the worker.

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

* docs(kaizen): scope managed-Harness build-vs-adopt spike for the headless lane

Surfaced while dogfooding scheduled runs: we use AgentCore Runtime (BYO
container), not the newly-GA managed Harness. Brief evaluates adopting the
managed Harness as the backing for the headless/scheduled/proactive lane
only (interactive chat stays build — it needs hooks/custom-loop/MCP-Apps
the managed Harness forbids). Captures pros (managed memory fixing the
write-only-in-cloud gap, versioned endpoints, Step Functions, export escape
hatch), cons, and three gating spike questions. Queued for kaizen review.

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

* feat(schedules): let schedule edits clear assistantId/enabledTools

B1's update_scheduled_prompt skipped None, so a PATCH could never detach a
schedule's assistant or reset its tool restriction — the SPA's clear
checkboxes were inert (a bare null reads as 'leave unchanged').

Add an explicit clear contract: an UNSET sentinel in the service
distinguishes 'omitted' (leave) from None (clear -> REMOVE the attribute).
UpdateScheduleRequest gains clearAssistant/clearTools booleans (rejected if
combined with a value). clearAssistant reverts to the default agent;
clearTools re-snapshots the caller's current RBAC-allowed tools, mirroring
creation so a schedule never stores an unresolved None. SPA sends the flags.

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

* fix(app-api): grant bedrock:ListFoundationModels to task role

The admin GET /admin/bedrock/models endpoint calls the Bedrock control
plane's ListFoundationModels, but the App API Fargate task role only had
bedrock:InvokeModel. In deployed environments the call raised
AccessDeniedException, which the app-wide AWS-error handler maps to a
generic 502 ("Upstream service error."). It only worked locally because
local dev runs with the developer's broader AWS credentials.

Add a BedrockListFoundationModels statement granting
bedrock:ListFoundationModels and bedrock:GetFoundationModel. These are
account-level list/read actions that do not support resource-level
permissions, so they are granted on `*`.

Requires a platform.yml (CDK) deploy to take effect.

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

* feat(sessions): unread indicator for scheduled-run deliveries

A scheduled (unattended) run delivers a session the user wasn't watching.
Surface it: the sidebar shows an unread dot until they open it.

- sessions/metadata.py: set_session_unread / mark_session_read — a targeted
  single-attribute UpdateExpression on the row's current SK (GSI-resolved),
  concurrent-safe with the title/activity writes; preview-session guarded;
  best-effort (never raises).
- harness/runner.py: mark the delivered session unread only on a *completed*
  run with trigger == "schedule" — attended "Run now" and failed/consent-
  blocked runs never set the dot (the user is present / there's nothing to
  read).
- app_api POST /sessions/{id}/read: clears the durable flag when the user
  opens the session; idempotent, ownership-enforced via the GSI lookup.
- SPA: durable server-persisted unread on SessionMetadata (survives reload,
  reaches other devices) ORed with ChatStateService's ephemeral in-tab
  unread for interactive background completions; session-list renders the dot.

Backend 108 + frontend 25 tests pass.

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

* docs(kaizen): queue Bedrock Mantle endpoint watchlist item

Phil-initiated kaizen focus: scope a future bedrock-runtime (Converse) ->
bedrock-mantle endpoint migration. Watchlist/Defer — strategic alignment
with where Bedrock capability lands first, not near-term need; Claude-on-
Mantle is Messages-API-only today and lacks cross-region + native
CountTokens + Guardrails, so the primary chat path stays on bedrock-runtime.
Interim low-risk value = finishing the already-scaffolded non-Claude
OpenAI-compatible Mantle lane.

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

* docs(kaizen): managed-Harness spike findings — 3 gating questions answered

Complete the #570 build-vs-adopt spike for the headless/scheduled lane.
Answered from the now-GA AWS managed Harness docs cross-checked against
our code and the proven F1 entrypoint spike:

- Q1 RBAC -> allowedTools: qualified yes (per-invoke globs; we already
  snapshot the RBAC-narrowed set statically at the app-api boundary).
  Non-membership gates relocate (quota/cost -> dispatcher; approval ->
  exclude on headless; consent -> Identity outbound).
- Q2 per-user tokens: yes on mechanism (OAuth-inbound customJWTAuthorizer
  == our authorizer; Gateway outbound == our USER_FEDERATION exchange).
  SigV4 cannot do per-user identity. One residual: customParameters
  vault-key pinning through the Gateway-managed exchange -> live probe.
- Q3 lose MCP Apps + SSE on headless: yes (interactive-only affordances;
  SPA loads the delivered session, not the harness stream).

Recommendation: green-light a narrow InvokeHarness probe to close the Q2
residual; interactive inference-api untouched.

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

* feat(sidenav): gate Scheduled Runs nav entry to system_admin

Hide the "Scheduled Runs" menu option from non-admins while the feature
is still maturing. Adds an isAdmin() check to the existing capability
gate, mirroring the admin-dashboard nav pattern. isAdmin is already
wired into the sidenav component from UserService; showSchedules keeps
its accessibility-probe behavior.

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

* docs(kaizen): managed-Harness Q2 live-probe result — GO-with-boundary

Records the live dev-ai probe (2026-07-06) that closes the Q2
customParameters residual from the spike findings.

Confirmed live (real CreateHarness/InvokeHarness via boto3): our exact
customJWTAuthorizer is accepted (harness READY); outboundAuth.oauth
customParameters is a first-class field persisted verbatim on GetHarness
(so we CAN pin the same params the consent flow uses); OAuth-inbound runs
as the owner (HTTP 200); the exchange calls the same GetResourceOauth2Token
our get_token_for_user uses; a failed exchange surfaces legibly as a typed
runtimeClientError stream event (maps to paused_reauth).

Boundary found: the managed Gateway 3LO (AUTHORIZATION_CODE) exchange
fails with "must provide a ResourceOauth2ReturnUrl" and does not source
that URL from defaultReturnUrl / OAuth2CallbackUrl header / workload
AllowedResourceOauth2ReturnUrl — a GA wiring gap. Cross-workload token
visibility (platform vs harness-own workload identity) unreached past it.

Decision: GO to adopt Harness on the headless lane, but keep
customParameters-sensitive / all 3LO connectors on our own
get_token_for_user until the return-URL wiring is resolved with AWS.
Aside: managed memory is on by default per harness (relevant to F5).

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

* feat(sessions): add mark-as-read/unread toggle with sidebar dot fixes

Adds a "Mark as read / Mark as unread" toggle to the session options menu
in both the top nav and the sidebar session list, backed by a new
POST /sessions/{id}/unread endpoint (mark_session_unread) that mirrors the
existing /read verb. The client surfaces the dot instantly via the
ChatStateService unread signal while the durable server flag lands async.

Fixes two sidebar-only bugs where the in-row options trigger lives inside
the row's group, so the CDK menu restoring focus to it on close tripped
group-focus-within:

- Bug 1: the ellipsis trigger stayed visible after a menu action. Switch
  its reveal from :focus / group-focus-within to :focus-visible, so
  mouse-restored focus no longer reveals it while keyboard nav still does.
- Bug 2: a freshly marked-unread dot didn't appear until the next browser
  interaction. The dot was being hidden by group-focus-within (restored
  trigger focus); scope that to group-has-[button:focus-visible] instead,
  and kick a synchronous refreshSessions() in the mark-unread branch
  (mirroring mark-read) so the OnPush row re-renders immediately.

The top-nav toggle was unaffected because its trigger sits outside the row.

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

* feat(schedules): interval cadence + "Run now" with background-task toasts

Two additions to the Scheduled Runs feature:

- Interval cadence: schedules can now run every N minutes/hours in addition
  to daily/weekly. Adds interval_value/interval_unit to the scheduled-prompt
  model and API, a MIN_INTERVAL_MINUTES floor enforced on create/update/
  resume, and interval_to_minutes() feeding compute_next_run_at. The schedule
  form gains the interval option with matching validation.

- "Run now": run a schedule's prompt headlessly on demand from the schedule
  form. RunNowService fires POST /runs/now (existing app-api endpoint) as
  fire-and-forget and reports progress through a new app-wide toast system —
  BackgroundTaskService (signal-backed task list) rendered by the
  BackgroundTaskToastsComponent mounted in app.html. On completion the run
  materializes as a real session; the list is refreshed and the toast offers
  a "View" affordance.

Tests: backend test_schedules_routes / test_scheduled_prompts /
test_harness_runner (100 passed); frontend schedule-form, run-api, run-now,
background-task, and background-task-toasts specs (30 passed).

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

* docs(memory): reframe spec as Memory Spaces — bindable, templated, shareable

Reframes the per-user markdown memory spec into the Memory Space primitive:
a named, first-class, bindable, templated, and shareable markdown wiki.
Oliver becomes a Chief-of-Staff template + a bound agent rather than a
special-cased feature.

- Adds the three-layer abstraction (Agent / Memory Space / declarative
  binding) and the structural-config vs. semantic-MEMORY.md split.
- Space-keyed storage (SPACE#{id} + membership records + UserSpacesIndex
  GSI) so sharing is expressible from day one.
- Entry types (entity / episodic / fact), Space Templates, and manifest-
  indexed fields for relational/temporal queries ("who owes what").
- Sharing via owner/editor/viewer grants mirroring assistant collab-edit,
  with run-as-user write attribution.
- Data governance is proportionate: same data class as sessions/artifacts
  behind the same Entra JWT + RBAC — identity-based, no content-inspection
  gate. Deletion-purge + inherited encryption are the only real items.
- 8-PR phasing; PR-1 (data layer) is the next buildout phase.

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

* docs(kaizen): recover resourceOauth2ReturnUrl shape section into harness findings

PR #576 merged from a state prior to commit 908f7e18, orphaning the
resourceOauth2ReturnUrl parameter-shape + harness-security cross-check
subsection. This re-applies it onto develop.

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

* docs(memory): bake in full-ownership zip export of a Memory Space

Add a first-class "download the entire space as a .zip of raw markdown"
capability (index + all entries, structure preserved, metadata.json),
framed as the user-ownership / zero-lock-in property. Promotes the stubbed
export endpoint into §9 with contents, access, streaming mechanics, and an
import-friendly round-trip note; folds the zip export into PR-5.

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

* feat(memory): Memory Spaces data layer (PR-1)

Adds the F5 Memory Space primitive's data layer — no runtime wiring,
gated by MEMORY_SPACES_ENABLED (default off).

Backend (apis/shared/memory/):
- store.py: S3 content-addressed byte store (sibling of the skills store)
- models.py: MemorySpace / MemoryIndex / MemoryEntryRef / SpaceMember
- templates.py: Blank / Chief-of-Staff / Research-Notebook presets
- repository.py: dedicated memory-spaces table CRUD (META/INDEX/MEMBER
  rows, OwnerIndex + MemberIndex GSIs, Decimal handling)
- service.py: permission-gated lifecycle + sharing + entry/index I/O,
  resolve_permission chokepoint (viewer reads, editor writes, owner
  shares/deletes), content-addressed writes with GC-on-replace
- feature_flags.py: memory_spaces_enabled() (default off)
- 47 moto-backed tests; import boundaries clean

Infrastructure:
- MemorySpacesConstruct: S3 bucket + dedicated memory-spaces DynamoDB
  table (OwnerIndex/MemberIndex GSIs), a per-domain table matching the
  project's actual pattern
- Threaded via PlatformComputeRefs to both compute roles (readwrite S3 +
  DynamoDB); env vars S3_MEMORY_SPACES_BUCKET_NAME /
  DYNAMODB_MEMORY_SPACES_TABLE_NAME / MEMORY_SPACES_ENABLED
- config.ts flag default-off; resource-count assertions updated
- tsc + 429 jest tests pass

Spec updated to reflect the dedicated-table + two-GSI decisions.

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

* docs(memory): re-slice phasing into primitive vs. agent-consumption workstreams

Splits the plan into two workstreams to keep the Memory Space a clean
bindable primitive: Workstream A (this epic) delivers the primitive + the
user-facing "own your data" surface (data layer, app-api CRUD, export,
sharing, SPA panel, consolidation); Workstream B (Agent/Harness layer)
delivers agent-consumption — the memory_* tools, declarative binding, and
system-prompt index injection — so any run surface can bind the same
primitive rather than welding it to inference-api.

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

* feat(memory): Memory Spaces user surface — /memory/spaces CRUD (A2)

Workstream A2 of the re-sliced memory epic: the user-facing "own your data"
surface over the Memory Space primitive. No agent-consumption (tools /
binding / prompt injection) — that's the Agent/Harness workstream.

app-api (apis/app_api/memory_spaces/):
- routes.py: /memory/spaces CRUD over MemorySpaceService — list (with
  templates + accurate per-space role), create-from-template, get (index +
  entry manifest), delete-or-leave, entry read/list/upsert/delete, index
  read/update. Sync handlers (FastAPI threadpools the sync boto3 service).
- Gated by require_memory_spaces_user: 404 while MEMORY_SPACES_ENABLED off
  (surface behaves as unmounted); cookie auth via get_current_user_from_session.
- Service errors translated NotFound->404, Permission->403, Error->400.
- models.py: camelCase request/response models.
- Mounted before the existing /memory (AgentCore Memory) router; paths are
  non-overlapping (/memory/spaces vs /memory/{record_id}).

shared service:
- leave_space(): a member drops their own grant (the shared-in forget-me
  case); owner cannot leave.
- list_spaces_for_user() now returns (space, role) so shared-in spaces carry
  the member's real viewer/editor grant (only consumer is the new route).

Tests: 12 route tests (moto-backed real service, flag gate, CRUD, 403/404,
member-leaves-via-delete) + leave_space service tests. Full memory suite +
import boundaries green (69 passing).

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

* feat(memory): Memory Space zip export — /memory/spaces/{id}/export (A3)

The "own your data" leg of Workstream A: a loss-free `.zip` download of a
space's raw markdown (§9). `MemorySpaceService.export_space` gathers the
corpus once (index + every entry's bytes) behind the viewer+ permission
gate, including the member grant list only for editor+ callers (mirrors
`list_members`). The app-api route builds the archive in a
`SpooledTemporaryFile` — spilling to disk beyond 8 MiB so a large space
never pins memory — and streams it back.

Zip mirrors the S3 layout (`{name}/MEMORY.md`, `entries/<type>/<slug>.md`,
`metadata.json`) so it is self-contained and re-importable later. Archive
path components are sanitized against zip-slip. Route tests cover layout,
verbatim frontmatter, owner-vs-viewer member disclosure, 403/404/flag-off,
and the hostile-slug case.

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

* feat(memory): Memory Space sharing + optimistic manifest concurrency (A4)

The sharing leg of Workstream A, plus the concurrency guarantee that makes
multi-editor spaces safe.

Sharing surface (app-api, over the existing service grant methods):
- GET    /memory/spaces/{id}/shares          list grants (editor+)
- POST   /memory/spaces/{id}/shares          grant viewer|editor (owner)
- PATCH  /memory/spaces/{id}/shares/{email}  change a grant's role (owner)
- DELETE /memory/spaces/{id}/shares/{email}  revoke (owner, idempotent)
New `MemorySpaceService.update_share` gives PATCH proper not-found semantics
and preserves the grant's original createdAt (distinct from share's upsert).

Optimistic manifest concurrency (the real design content):
- `MemorySpaceRepository.put_index(expected_version=…)` does a conditional
  DynamoDB write on the manifest `version`, raising the repository-local
  `OptimisticLockError` on a mismatch.
- `write_entry`/`delete_entry` route through a new `_mutate_index` helper: a
  bounded read-modify-conditional-write retry loop. Because an entry write
  touches a single slug, re-reading the fresh manifest and re-applying is safe;
  it converges on transient races and raises `MemorySpaceConcurrencyError`
  (→ 409) only on a sustained one. Behavior is unchanged for single-writer
  spaces.

Tests: 6 route tests (share CRUD, member gains access, non-owner 403, viewer
can't list, PATCH-unknown 404, owner-role 422) + 7 service tests (update_share
role/origin/owner-gate + version-increments, stale-write rejected, retry
converges, gives-up-after-max). 76 memory + import-boundary tests green.

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

* feat(memory): Memory Spaces SPA panel — list/detail/create/share/export (A5)

The user-facing "Memory" surface for the Memory Space primitive, under
frontend/ai.client/src/app/memory-spaces/. Makes A2–A4 visible to users.

- List page: owned + shared-in spaces as cards with role/template badges;
  per-card open, share (owner), download .zip, delete/leave. Empty, loading,
  error, and feature-unavailable states.
- Detail page: view/edit the MEMORY.md index (editor+) and the entry list;
  entries open in a dialog to view (viewer) or edit/create (editor+); delete
  per entry. Header carries share/download/delete-or-leave.
- Create-from-template dialog and a share dialog (add-by-email + per-row role
  + delta-on-save over the A4 /shares endpoints). Viewer access is read-only
  throughout; the share dialog fails soft for non-owners.
- Signal facade (MemorySpaceService) + thin API service mirror the
  assistants/schedules pattern. The nav entry rides a live accessible$ probe:
  a 404 (MEMORY_SPACES_ENABLED off) hides it, matching showSchedules.
- Routes memory-spaces + memory-spaces/:id (authGuard); redesign-tokens and
  @angular/cdk/dialog conventions throughout.

Facade spec (7 tests) green; dev build + tsc clean; sidenav specs still pass.

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

* test(memory): mock MemorySpaceService in sidenav spec (fix unhandled rejection)

The A5 sidenav now injects MemorySpaceService and probes `loadSpaces()` in the
auth effect. The sidenav spec mocked ScheduleService but not the new service,
so the authenticated-probe test constructed the real MemorySpaceService, which
fired a real XHR to /memory/spaces (status 0, no backend); `loadSpaces` then
re-threw, surfacing as a Vitest "unhandled rejection" at suite level.

Mock MemorySpaceService exactly as ScheduleService is mocked, and add matching
coverage: probe fires once authenticated (not while unauthenticated) and the
`showMemorySpaces` nav gate resolves null→false, false→false, true→true.

Full suite: 1422 passed, 0 errors.

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

* fix(memory): wire memory-spaces table/bucket names onto app-api

app-api owns the Memory Spaces CRUD surface (`/memory/spaces/*`) but its
container environment only set MEMORY_SPACES_ENABLED — never the table or
bucket names the service reads (DYNAMODB_MEMORY_SPACES_TABLE_NAME /
S3_MEMORY_SPACES_BUCKET_NAME). Without them the repository falls back to the
default "memory-spaces" table name, which doesn't exist, so every read throws
a boto3 ResourceNotFoundException that the centralized handler maps to a
502 "Upstream service error." (inference-api already sets the identical trio,
but per the service-boundary rule it isn't the one serving these routes.)

Thread `refs.memorySpacesTable`/`.memorySpacesBucket` through AppApiSsmParams
and emit both names next to the flag in buildAppApiEnvironment. Names are
always wired (read lazily); only MEMORY_SPACES_ENABLED gates route mounting,
so flipping the switch on later needs no env change. New unit test guards the
wiring; tsc + 431 infra jest tests green.

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

* docs(cdk): capture the "wire resource name to every compute" rule

Fold the lesson from the app-api env-wiring fix into the cdk-infrastructure
skill so the next construct-author sees it while wiring, not after a 502.
Adds a "Cross-Construct References" subsection: set a resource's name env var
on every compute that reads it (one doesn't imply the other), the silent-502
failure mode (default-name fallback → ResourceNotFoundException → generic 502,
invisible to synth/CI), and the env-map test guard + service-boundary caveat.

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

* feat(memory): deterministic consolidation health pass (A6)

The safe, non-LLM slice of Workstream A6. `MemorySpaceService.consolidate`
(editor+) + `POST /memory/spaces/{id}/consolidate` → a `ConsolidationReport`.

Auto-fixes only storage hygiene: orphaned content-addressed objects — keys
under a space's prefix that no manifest entry or the index pointer references
(leaks from crashed/raced writes) — are GC'd (new `MemorySpaceStore.list_keys`
drives it). Everything that needs a judgment call is *reported, not mutated*:
- duplicate content across slugs (same content hash) — which slug survives is
  semantic, so it's flagged, never auto-merged;
- dead `[[slug]]` wikilinks in MEMORY.md — reported; opt-in `stripDeadLinks`
  unlinks them (they point nowhere) while preserving the surrounding prose;
- over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200) — flagged, never
  auto-evicted.

This deliberately does not merge/evict/rewrite durable memory — that's deferred
to the LLM consolidation pass (Workstream B era), which extends this exact
`consolidate()` seam once agentic writes create real duplication/staleness to
act on. On-demand only for now; scheduler/threshold auto-run and SPA surfacing
are follow-ups.

Tests: 8 service (healthy report, orphan GC + skip, dup-report-no-merge,
dead-link report + strip-keeps-prose, over-cap flag, editor gate) + 4 route
(report shape, no-body, viewer 403, flag-off 404) + 3 store (list_keys prefix
scoping / empty / disabled). 104 memory + boundary tests green.

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

* docs(agent): Agent Designer spec — unified primitive-binding surface

Captures the strategy for the "Agent Designer" (Agent Harness Editor): a new
authoring surface that composes an Agent from RBAC-governed primitives
(instructions, model, KBs, tools, skills, Memory Spaces, + future), replacing
the term/feature "Assistant."

Locks the load-bearing decisions: own a primitive-agnostic Agent contract and
federate AgentCore Registry later rather than build on it (adopt-with-boundary
precedent); evolve the assistant store in place (no parallel table); a uniform
bindings[] model with the model as a governed single-select; RBAC = compose the
five existing per-primitive access checks (incl. ModelAccessService), not a new
system; design-time filter + run-time re-resolution per invoker with block-on-
missing v1; ship memory-consumption as a thin vertical slice before the full
Designer. Phasing 0–5 + later AWS federation; supersedes the memory spec's
"extend the Assistant" §B1 framing.

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

* feat(agents): Agent contract + compat mapping in shared assistants models

Phase 1 (PR-1) of the Agent Designer. Pure library, zero behavior change:
legacy Assistants read unchanged and no caller passes the new fields yet.

- AgentModelConfig (D3 governed single-select; field is model_settings/
  alias modelConfig to dodge pydantic's reserved model_config — R3)
- AgentBinding (open kind on read, KNOWN_BINDING_KINDS for request validation)
- optional model_settings + bindings on Assistant (additive)
- compat.effective_bindings/to_agent_view (D2): absent bindings synthesize a
  knowledge_base binding reffing the assistant id (KB's only stable identity,
  F4 deferred — R4); absent model maps to None, never fabricated (R1)
- Decimal-safe serialization for modelConfig.params floats

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

* feat(agents): persist bindings + modelConfig with design-time validation

Phase 1 (PR-2). The Agent fields now round-trip through the rag-assistants
store and are validated at write time by composing existing RBAC checks (D4).
Legacy clients are unaffected: the SPA sends none of the new…
@philmerrell
philmerrell merged commit 73f0e68 into develop Jul 16, 2026
4 checks passed
@philmerrell
philmerrell deleted the backmerge/main-into-develop-1.6.1 branch July 16, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant