Skip to content

chore: backmerge main into develop after 1.27.0 - #1522

Merged
philmerrell merged 2 commits into
developfrom
backmerge/main-into-develop-1.27.0
Oct 9, 2026
Merged

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

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Backmerges main into develop after the 1.27.0 release, Release/1.27.0 (#1520), so main is an ancestor of develop again and the next release doesn't conflict on the release files.

Merge this with a merge commit, not a squash. A squash recreates the divergence this PR removes.

  • develop had no commits after the release was cut (180867a), so both conflicts (CHANGELOG.md, and ios/Config/Version.xcconfig as add/add) were resolved in main's favor.
  • The resulting tree is identical to main. Against develop, it changes only the release artifacts: VERSION, CHANGELOG.md, RELEASE_NOTES.md, README.md, the manifests and lockfiles, and ios/Config/Version.xcconfig.
  • sync-version.sh --check passes, and git merge-base --is-ancestor origin/main HEAD succeeds.

🤖 Generated with Claude Code

philmerrell and others added 2 commits October 8, 2026 21:36
* fix(shares): make a shared-conversation fork usable from its first turn

"Continue in my own task" copied the share snapshot's display shape into
the fork's Converse history. Four defects followed:

- Document/image blocks went in with no `source`, so Bedrock rejected every
  turn once the agent was rebuilt. They now become one line naming the
  attachments that were not copied; images/documents inside tool results
  become text placeholders.
- No AGENT record was written, so Strands' initialize() took the new-agent
  branch and the fork's first turns ran on an empty history. Export now
  writes the record (conversation-manager state the runtime's manager
  restores) before the messages.
- A user message carried the model-facing prompt (RAG context, notes, the
  attachments marker). It now carries the author's displayText.
- Session files are not copied (they are the owner's uploads); the note
  says so, for the forker and the model alike.

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

* fix(session): repair image/document blocks with no source on restore

Forks created before the export fix hold document/image blocks with no
`source`, and Bedrock fails every turn on them. Restore now replaces such
blocks (top level and inside tool results) with a text placeholder. Healthy
blocks pass through as the same objects, and the repair is a pure function
of the stored block, so restored history stays byte-stable.

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

* fix(kb-ingestion): write Word tracked changes out as text before indexing

Neither ingestion engine understands w:ins / w:del. Managed knowledge bases
parse the raw .docx with Bedrock's parser, which flattens a redline (B9 saw
deleted and inserted wording concatenated; an inline parse keeps only the
insertions). Legacy ingestion goes through Docling and python-docx, whose
Paragraph.text only reads direct-child runs, so both sides of every change
vanish.

kb_backend/docx_revisions.annotate_tracked_changes rewrites the package so each
revised span is ordinary text — [deleted: ...] / [inserted: ...] — adds a note
paragraph naming the authors, and inlines comments at their anchor. Each
engine's own parser still handles the rest of the document. Stdlib only, so it
fits both Lambda images unchanged.

Managed: a .docx with tracked changes is sent as inline BYTE content (capped at
3,932,160 raw bytes by ByteContentDoc's base64 limit); anything else, and any
failure, ingests from S3 exactly as before. Legacy: Docling converts the
annotated copy. Documents without tracked changes keep their original bytes.

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

* fix(projects): index a new memory file automatically on save

Only a space's MEMORY.md is injected into a project task, and indexing was
a separate save the model usually forgot: in the 2026-10 team simulation
two of three team files and a personal preference were orphaned (G2/G17).

The harness memory_save now appends "- [[slug]] — description" to the
scope's index when it creates a file (version 1), through a new
MemorySpaceService.add_index_link: skipped when the index already links
the file or its alias, or when the line would push the index past the
scope's injection budget (2k project, 1k mine), with a nudge instead. The
META write is conditional on the index hash, so concurrent creates keep
both lines. Updates never touch the index. Ordinary Agents' memory tools
are unchanged (spec hashes still pinned).

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

* fix(projects): tell the project harness which member is speaking

A project's harness had no idea who was talking to it and told the
export-control specialist to "send this to" her own name (G18 of the
2026-10 team simulation).

Each harness turn now prepends a short <project_member> note (name, email,
project role) to the user message, the same position as the interruption
note: persisted in append-only history, hidden from the UI by the
displayText split, and never in the system prompt or toolConfig, which
every member shares. The role is the one the access check already
resolved, so the turn makes no extra read.

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

* fix(projects): make a member's personal preference persist and apply

A "just for me" preference saved in one project task wasn't applied in the
next (G17 of the 2026-10 team simulation). After #1432 indexed new files,
two causes remained, both reproduced on dev with Haiku 4.5:

- Asked to "remember this just for me", the harness never called
  memory_save: it kept the preference "in my context for this session
  only". memory_save now says to save such a request to "mine" right away.
- A saved preference whose index line only named it ("My status-check
  reply format") was not read in the next task. memory_save now asks for a
  preference's description to be the rule itself, so the auto-indexed line
  carries it, and the "mine" block's intro says to follow its preferences.
  The intro also names the member instead of "you", which the model read
  as its own memory (the confusion G18 hit).

Local A/B on dev data, Haiku 4.5, 4 samples each with a unique reply token
per sample: develop saved 0/4 and applied 0/4; this change saved 4/4 and
applied 4/4. Changes the harness memory_save spec and the mine intro once;
ordinary Agents' memory tools and the agent block stay byte-identical.

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

* fix(agents): stop blocking shared agents that use their owner's skills

The runnability preview diffed an Agent's skill bindings against the
viewer's own skill palette (role grants + skills they wrote). The run-time
resolver also admits skills the Agent's owner wrote (invoke-through), so
every owner-authored skill read as "missing" for anyone the Agent was
shared with. The detail page then disabled Launch and the chat showed
"isn't available to your account" for an Agent that would have run.

Skills now diff against resolve_invocable_skill_ids, the same predicate
agent_binding_resolver uses, so the preview and the turn agree.

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

* feat(agents): name the owner's skills that sharing an agent hands over

Invoke-through (Skills v2 §6/D7) resolves an Agent owner's own bound skills
for anyone the Agent is shared with, and those people can get the model to
show the skill instructions. Only the marketplace submit dialog said so; the
share dialog, where the author adds people or sees the Agent is PUBLIC, did not.

- listing_service._exposed_skills becomes exposed_skills, the one rule both
  disclosures read (owner_id == agent.owner_id, same as the turn path).
- New owner-only GET /agents/{id}/shares/exposed-skills (session-cookie auth,
  gated on AGENTS_API_ENABLED only, not the marketplace flag). It applies that
  rule to what a share recipient runs: the published snapshot when there is
  one, falling back to the live record if the snapshot cannot be read.
- The share dialog lists those skills once the Agent reaches anyone (PUBLIC,
  or someone on the working list, so it appears before Save), in a live region.

Read-only: neither resolve_accessible_skill_ids (the viewer's picker) nor
resolve_invocable_skill_ids (the turn path) is touched.

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

* fix(kb-ingestion): send annotated redlines to managed KBs as text, not .docx

Dev validation of #1431 showed that a managed knowledge base provisioned with
image extraction enabled (every one this platform creates) parses an uploaded
.docx through a model that treats the [deleted: ...] markers as instructions:
"[deleted: Idaho]" was indexed as "[deleted: ]", "[deleted: Ada County, Idaho]"
as "[deleted: Ada County, o]", and the note's "...]" placeholders vanished.
The markers survived; the deleted wording did not.

The managed path now sends docx_revisions.annotated_text — the rewritten
document as plain text, tables one line per row, footnotes after the body — as
inline TEXT content, which Bedrock chunks as given. Re-ingested on dev, all
three redlines came back verbatim. Docling (legacy) still converts the
rewritten .docx, which it reads deterministically.

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

* docs(projects): plan share notifications and shared-task reading in 2.5

The dev team simulation (docs/testing/projects-team-simulation-2026-10.md,
gaps G5, G6 and G15) showed team knowledge stranded in shared tasks: the
assistant could not read them and nobody was told they existed, so
counsel's escalation summary listed 2 of 7 items.

Split 2.5 into 2.5a (memory governance, unchanged scope), 2.5b (opt-in
notifications and a note when sharing a task to the project) and 2.5c
(harness-only tools that list and read shared-task snapshots, bounded,
with constant specs so the cached prefix and time to first token are
untouched). Data model, API, agent-tool and configuration tables
updated to match.

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

* fix(shares): show finished tool steps as complete in shared snapshots

The shared view passed snapshot messages straight to the message list,
skipping the toolResult->toolUse fold the session page runs on load, so
every call kept its default pending status and the rail read "Running
memory read". Move the fold into a pure helper and run it (plus the
steering normalization) on the snapshot too.

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

* fix(projects): say a project re-share replaces the project's snapshot

A project share is listed once per task, newest wins, so "Creating a new
share will add another snapshot" was wrong for Project members when the
task is already shared with the project. Public and limited shares keep
the existing copy.

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

* fix(projects): archived-project turn refusal covers existing tasks

The gate also refuses continuing an existing task, so "can't start new
conversations" misdescribed it. Say the project is read-only instead.

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

* docs(projects): stop listing fixed bugs as current limits

The Shared Projects user guide (#1428) described several bugs as limits.
They have since been fixed: fork history and attachments (#1430), KB
throughput for a new project (#1429), tracked changes in Files (#1431,
#1436), memory auto-indexing and member identity (#1432), and personal
preferences (#1433). The guide now describes the current behavior. The
contract-negotiation walkthrough keeps the test run as it happened and
marks each lesson that has been fixed since.

The simulation report gains a Status section mapping findings to the PRs
that fixed them.

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

* fix(spa): keep a project task's project when switching models

Picking a different model in an open conversation starts a new one, and it
navigated to a bare `/`, dropping the conversation's `assistantId`. In a
Shared Projects task that left the project silently: the new chat had no
project assistant, instructions, files or memory (B8 in the 2026-10 projects
team simulation). An agent session lost its agent the same way.

The new conversation now keeps the URL's `assistantId`, so a project task
starts a new task in the same project and an agent session a new session with
the same agent. The menu says so before the choice ("Choosing another model
starts a new task in this project."), and each row's pill reads New task /
New session / New chat to match. A project or agent that pins a model already
locks the picker, so that case is unchanged.

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

* fix(projects): show people by name, offer ownership to signed-in editors, drop user ids from memory

G8: Projects responses carry a display name beside every email (members,
project owner, shared tasks, files, settings history, the audit page's
`people` map, and the inbox's `actorName`), resolved from the directory's
per-process users-table snapshot rather than per-row reads. Email stays the
key; the name is null when the directory has none, and the SPA falls back to
the email (`personLabel`), with the email secondary or on hover. A directory
failure shows emails and never fails the request.

G9: `hasSignedIn` now means signed in to the platform (the member row is
bound, or the directory knows the email), not "has opened this project".
Transfer binds an unbound editor to the directory's account for their email
(the most recent sign-in when the users table holds duplicates), so "Make
owner" works before they open the project; an editor who has never signed in
is labelled "Can become owner once they've signed in". The access-time
back-fill is unchanged and still never overwrites.

G12: memory-spaces `updatedBy` is the editor's email, plus `updatedByName`,
for entries and file history; a project's space returns no `ownerId`. No SPA
code read either field.

The directory snapshot now loads off the event loop (a sync users-table page
read run in a thread, single-flight under a lock) and keeps the held snapshot
when a refresh reads empty.

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

* feat(projects): notify members on archive/restore and the owner when someone leaves

Archiving or restoring a project now notifies every member (pending invitees
included) through a new batched NotificationService.notify_many; leaving
notifies the owner, and the bell opens the Members tab. Pulled forward from
2.5b's deferred list (G20): both events are rare, so they need no per-member
preference. A failed member read or write is logged and never fails the
archive.

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

* feat(shares): freeze tool-batch summaries into shared snapshots

create_share already fetched the session's tool summaries from get_messages
and dropped them, so a snapshot's rails fell back to the client-side wording
("Ran memory read"). Store them in the snapshot body, return them as
toolSummaries, and seed them in the shared view under a share:<id> key that
the assistant message reads in place of the viewed session. The view now
reads the S3 body once instead of twice.

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

* docs(projects): describe model switching as it works after #1440

Switching models inside a project task now starts a new task in the same
project instead of a non-project chat. The guide's caution and limit
described the old behavior; the simulation report's Status section now
lists B8 as fixed.

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

* docs(testing): mark B7, B10 and G20 fixed in the Projects simulation status

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

* fix(spa): stop announcements and connectors fetching before sign-in

AnnouncementModalService starts from an app initializer and pulls in
AnnouncementsService and (via OAuthConsentService) UserConnectorsService.
A `resource` fetches on construction, so every page load requested
/announcements/ and /connectors/ in parallel with /auth/session - and
/auth/login got a 401 for each.

The announcements feed now waits for an authenticated session. The
connectors list additionally waits for a surface that shows it (the
Customize > Connectors page or an in-chat consent prompt) to call
ensureLoaded(), so signed-in page loads no longer pay for it either.

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

* fix(spa): reset login buttons when restored from back/forward cache

Pressing Back from Cognito restored /auth/login from the bfcache with
the clicked button still disabled and showing "Connecting...". Reset
the loading state on a persisted `pageshow`.

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

* fix: retry transient GPT-6 faults instead of surfacing "Agent force-stopped"

The bedrock-runtime Responses provider (GPT-6, GPT-5.6) got no retry
strategy, and Strands reads retry_strategy=None as retries off. OpenAI
reports server faults as events inside an HTTP 200 stream, which the
OpenAI client's own retries never see, so every transient fault ended
the turn on the first attempt. The force-stop classifier did not
recognize either OpenAI message, so users saw the raw
"Agent force-stopped: ..." text marked non-recoverable.

- ResponsesTransientRetryStrategy retries ModelThrottledException and
  OpenAI server/transport faults with the existing backoff budget
- The Responses model tags a stream failure that follows visible output,
  and the strategy never retries those, so a restart cannot duplicate
  text the user already saw
- "temporarily unavailable" and OpenAI's server-error text join the
  service-unavailable markers, giving the friendly recoverable copy
- force_stop reasons are logged at WARNING
- RETRY_TRANSIENT_SERVICE_ERRORS=false restores the old no-retry path

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

* docs: conversation search plan (spec for #1380)

Plan for searching a user's conversation history without building a
new index. Message text lives only in AgentCore Memory events (90-day
expiry), but every session already has an AgentCore summary record that
outlives that expiry, is purged on session delete, and is searchable
across all of a user's sessions in one RetrieveMemoryRecords call via
namespace prefix. The spec pairs that semantic leg with a lexical leg
over new titleLower/firstPrompt row attributes, prices the options
(S3 Vectors, Managed KB, OpenSearch, own Nova summaries) and phases the
work behind CONVERSATION_SEARCH_ENABLED with a Phase 0 probe gate.

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

* fix: one retry strategy for every provider, and the only retry layer

Every provider but Bedrock got retry_strategy=None, which Strands reads
as retries off, so OpenAI-family, Mantle and Gemini faults reached users
on the first attempt. Where retries did exist they compounded: 3 botocore
attempts under 4 SDK attempts, up to 12 Bedrock calls per invocation.

- TransientModelRetryStrategy replaces BedrockTransientRetryStrategy and
  goes to every provider: throttles, Bedrock transient codes, 5xx,
  connection failures and timeouts, OpenAI in-stream server faults, and
  Gemini 5xx
- While it is active it is the only layer: botocore makes one attempt
  (RetryConfig.transport_max_attempts) and the OpenAI client gets
  max_retries=0 on a copy of client_args, so a call is tried at most
  RETRY_SDK_MAX_ATTEMPTS times
- tag_partial_output now wraps every model's stream in the factory, so
  no provider retries after visible output; this replaces the
  Responses-only override
- RETRY_TRANSIENT_SERVICE_ERRORS=false restores the previous wiring,
  transport retries included

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

* docs: revise conversation search plan around a Managed KB index

Revised after accepting new resources where they serve users better.
Two measured facts drive it: 1,439 of 13,057 active prod sessions
(11%) are past the 90-day AgentCore event expiry and open empty, and
Managed KB has no floor cost ($0.001/query, $5/GB-month) with hybrid
keyword+semantic retrieval and reranking the repo already wires.

Plan is now: raise event expiry to 365 (CFN no-interruption update),
archive each turn to S3 after `done`, feed one shared Managed KB of
per-turn documents filtered by user_id, and search it alongside a
lexical title leg with jump-to-message. The summary-only route stays
documented as the no-new-resources fallback. ~$4/month today.

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

* docs: conversation search — index archived + forks, one retention variable

Decisions: archived sessions and shared forks are indexed (fork copy
path writes archive objects; lexical leg queries the base table so
archived rows are found). Retention becomes one variable,
CDK_CONVERSATION_RETENTION_DAYS (default 365): Memory eventExpiryDuration
= min(n, 365), archive bucket lifecycle = n, KB documents deleted by the
same consumer on S3 "Object Deleted" (Lifecycle Expiration), read-path
cut and a daily reconciler as safety nets, optional session-row pruning
behind CONVERSATION_RETENTION_PRUNES_SESSIONS.

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

* docs: conversation search — session pruning past retention on by default

CONVERSATION_RETENTION_PRUNES_SESSIONS defaults on with =false as the
opt-out; the reconciler's first prod run is a dry run and it never
prunes a continuable or interrupted session.

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

* refactor!: drop the retry kill switch and botocore retry settings

TransientModelRetryStrategy is now unconditional. The switch it rode on
was never wired through CDK or a workflow, so flipping it in a deployed
environment meant hand-editing the runtime env, which the next deploy
undid; a revert is as fast. It had also met its retirement condition
(shipped in 1.17.0, never flipped).

botocore always makes one attempt now, so its attempt count and mode
settings no longer mean anything.

Adds an end-to-end test through a real Strands Agent with a scripted
model, which covers the fault path dev cannot produce: a fault before
output retries and streams once, a fault after output is not retried,
a persistent fault stops at sdk_max_attempts, a 400 is not retried.

BREAKING CHANGE: RETRY_TRANSIENT_SERVICE_ERRORS, RETRY_BOTO_MAX_ATTEMPTS
and RETRY_BOTO_MODE are removed and ignored if set.

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

* fix: map unsupported-attachment rejections to a friendly message

A text-only model (zai.glm-5) rejects document blocks with "This model
doesn't support documents." and images with "This model doesn't support
the image content block". The raised ValidationException reached the
generic stream-error copy with the raw AWS text, and the force-stop check
for images never matched Bedrock's actual wording ("the image").

One shared matcher, unsupported_attachment_message, now serves both the
force-stop path and build_conversational_error_event. The raised path keeps
the raw reason quoted, because that message is persisted as the assistant
turn and is the model's context on the next one.

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

* fix: give text-only models document text instead of document blocks

Nothing on the turn path read a model's inputModalities, so a model whose
catalog row declares TEXT input only (zai.glm-5 on dev) received native
document and image blocks and Bedrock failed the turn: smoke_turns
--with-attachments --model-id zai.glm-5 failed attach_pdf with a
stream_error.

- _resolve_model_settings returns the row's input_modalities; TurnModel
  carries them and exposes text_only, keyed on TEXT-only (Claude rows
  declare TEXT+IMAGE with no DOCUMENT and read PDFs natively).
- _adapt_attachments_for_model runs once the model is known: documents
  become their text layer (document_digest.document_text — whole when it
  fits, else text_sample's excerpt), bounded by
  TEXT_ONLY_DOCUMENTS_MAX_CHARS (60k chars) per turn and rendered into the
  user message; images and text-less documents are dropped and named in
  the attachment guidance, like oversized files.
- document_read's page-range mode returns page text for a text-only model
  instead of a native document block; the tool spec is unchanged.

Every other turn returns on the first check. Extraction runs off the loop,
only on turns that used to fail: 0.5ms for 1 page, 22ms for 100 (warm).

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

* feat(sidenav): filter conversations by title in the sidebar

Adds a search box above the conversation list that filters the loaded
sessions by title, case-insensitively, as the user types. PR-1 of
docs/specs/conversation-search.md (#1380): client-side only, no API,
no feature flag.

- While a query is held the date buckets and project headings give way
  to one flat list in recency order, with a live match count and a
  "No matching conversations" state distinct from "No Chats Yet".
- The sidebar pages 30 at a time, so the filter spans loaded pages only.
  A muted "Searching loaded conversations" line says so until the last
  page is in, and the load-more sentinel stays live under the results.
- Escape and the clear button empty the box. The query lives in a
  root signal (both sidenav mounts share it) and in sessionStorage,
  guarded, so it survives a refresh.
- The collapsed desktop panel and the closing drawer are now inert, so
  their controls leave the tab order while off-screen.

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

* feat(infra): conversation retention setting, Memory events 90 -> 365 days

Message text lives only in AgentCore Memory short-term events, and the
Memory resource hard-coded eventExpiryDuration: 90. Sessions past that
stay in the sidebar but open with no messages (11% of active prod
sessions on 2026-10-06).

Add CDK_CONVERSATION_RETENTION_DAYS, the single retention setting from
docs/specs/conversation-search.md section 3 (PR-R). Default 365; unset
or '' means 365. Below 3 or not a whole number fails synth. Above 365
is kept in config for the archive in PR-2 and clamped to 365 where
Memory reads it. Forwarded by platform.yml; no environment sets it.

Refs #1380

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

* fix(sidenav): raise light-mode contrast of sidebar date headings

The Today / Yesterday / Last 7 Days / Last 30 Days / Older headings were
gray-400 on the sidebar's gray-100 (2.36:1 at 11px), well under WCAG AA's
4.5:1. Use gray-600, matching the conversation filter's labels.

Two nearby strings in the same list failed by a hair at 4.39:1 (gray-500
on gray-100): the "Couldn't load more" row and the empty state's
"Get started" line. Both move to gray-600 too. Dark-mode classes are
unchanged; they already pass.

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

* feat(spa): warn when a text-only model will skip images or flatten documents

The backend now adapts attachments for a model whose catalog row declares
TEXT input only (zai.glm-5 on dev): documents arrive as extracted text and
images are dropped. The user only found out from the reply. processFiles now
shows an info toast once per batch per kind, beside the tabular nudge:

- images: "Images will be skipped"
- pdf/docx/txt/html/md: "This model reads text only" (layout and images lost)
- csv/xls/xlsx/pptx: nothing; they go to their tools whatever the model

isTextOnlyModel mirrors the backend's is_text_only_model: TEXT and nothing
else. Claude rows declare TEXT+IMAGE with no DOCUMENT and read PDFs natively,
so a missing DOCUMENT never counts. ModelService.DEFAULT_MODEL (the Haiku
fallback) now declares TEXT+IMAGE so it does not false-trigger.

The check reads the picker's selectedModel, which an Agent-pinned model
already drives via lockToAgentModel. An @-mentioned agent's model is chosen
by the backend for that turn and is not visible here.

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

* fix(sidenav): meet AA contrast on the active and hovered session rows

The selected conversation row set its title in the brand orange
(secondary-500, #d64309) on the bg-gray-200 highlight: 3.63:1 at 14px,
under WCAG AA's 4.5:1. Hover was also under it at 4.30:1 on gray-50,
as was the project-heading link's hover (12px).

The title text is now gray-900 in all three states. On the active row,
the orange moves to a 3px inset left bar, which only needs the 3:1
non-text minimum (3.64:1). A darker brand step was not used because the
secondary scale derives from each deployment's configured brand color,
so a fixed step is only AA for some brands. Dark mode is unchanged.

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

* fix: keep media blocks in history off text-only models

A conversation that sent an image to a vision model and then switched to a
model whose catalog row declares TEXT input only (zai.glm-5) still carried
the image block in history, and Bedrock rejected every turn ("This model
doesn't support the image content block"). The user saw the friendly
backstop, but the conversation was stuck on that model. Documents had the
same hole in a warm process: a model switch builds a second agent that
shares the live message list, where the restore-time digest never ran.

- apis/shared/models/text_only_input.py projects history per model call,
  replacing image, document and video blocks (top level and inside
  toolResult content) with a short deterministic text placeholder. It never
  mutates: the list is shared with every agent serving the session (#741),
  so only the path to a replaced block is copied, and a history with no
  media is returned as the same object.
- AgentFactory wraps the model instance's stream, count_tokens and
  native_count_tokens when ModelConfig.text_only is set. Every other model
  is untouched, so their call path is unchanged.
- text_only_model threads get_agent -> BaseAgent -> ModelConfig on the main
  turn, resume (new textOnlyModel snapshot field) and the MCP App dispatch.
  Not a cache-key element: it follows from model_id, which already is.

Cost on a text-only model only: 0.04ms per model call at 100 messages,
0.16ms at 400, 0.4ms at 1,000.

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

* fix: meter the model calls a failed turn completed

A turn that ended in an error left stream_response before the post-loop
block, which is the only caller of _store_message_metadata. The in-loop
error arm (force-stop, provider fault, max_tokens) returns early, and the
outer except arm jumps past it. So every call such a turn made was billed
by the provider but got no C# row, no session totalCost or messageCount,
and no user cost-summary update, and the quota never counted it.

The per-call write loop moves into _store_turn_call_metadata, which the
success path now calls. Both failure arms call _record_failed_turn_usage
after the error frames and done, so it adds nothing before the first
token. It bumps session activity and writes a row for each call that
reported usage, keyed to the success path's odd-position message ids.
A call that failed before reporting usage writes nothing. That covers a
retried attempt that never answered, and a turn that fails on its first
call. A turn_usage_recorded flag stops the except arm from metering
calls the success block or the in-loop arm already recorded.

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

* docs(specs): conversation search surface is a Cmd+K modal across scopes

Amend the conversation search spec after PR-1 (#1449) and PR-R (#1450)
merged: the search surface becomes a Cmd/Ctrl+K dialog over conversations,
projects, agents and artifacts, with the shipped sidebar filter kept as
the in-place quick filter. Adds §6a (modal design), re-scopes PR-4 and
adds PR-4b for the extra scopes. Backend design (§3–§5) is unchanged.

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

* fix: meter every model call an interrupted turn made

A Stop or a disconnect ends a turn in _persist_interruption, which wrote
at most one cost row from the turn-level accumulated_metadata. That dict
is last-write-wins, so it holds only the latest call's usage, and every
call completed earlier in the turn was billed by the provider but never
counted. It also wrote nothing when the Stop landed during tool
execution, and never bumped messageCount.

Both interruption arms now pass the per-call slots. _persist_interruption
bumps session activity and writes one row per call that reported usage,
through _store_turn_call_metadata, keyed to the success path's message
ids. The context-attribution projection is still used for the call in
flight, but only when one exists, i.e. when the history tail is the
turn's user or tool-result message. During tool execution it would
re-describe the last completed call.

Interruption arms now set turn_usage_recorded too. A disconnect that
lands while the success block or a failure arm is writing would
otherwise meter the same calls again, now that completed calls are
metered whatever the tail.

The metering tests move to test_unfinished_turn_metering.py, which now
covers failed and interrupted turns.

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

* fix: keep cost rows aligned with messages across model-call retries

A retried model call never becomes a message, since Strands commits only
a completed call to history. But the OpenAI Responses transport yields
messageStart as soon as the response opens, so a server error before any
content, which is what TransientModelRetryStrategy retries, left an open
slot in per_message_metadata. The retry opened another, and every later
call's cost row was keyed two message ids late, while the abandoned
attempt got a usage-less row on a message that does not exist.

The per-call hooks (prefix fingerprints, tool census, context ledger) are
a second index. They count every BeforeModelCallEvent, retried attempts
included, so any retry, with or without a messageStart, left their
lookups one call behind.

Strands yields exactly one EventLoopThrottleEvent (model_retry) per
retry, before the next attempt starts. On it, the coordinator now drops
the open slot of the abandoned attempt and counts the retry. Each slot
records its hook index (position plus retries so far) at message_start;
_store_turn_call_metadata uses it for the hook lookups and keeps the
position for the message id. The interrupted-turn projection carries the
retry count for a call that had not streamed yet.

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

* feat(projects): composer-first project page with a settings rail and dialogs

Replace the six-tab project page with the layout Claude Projects uses: the
composer and the caller's Recents down the middle, and the project's settings
as a rail of one-line summaries beside them, each opening a dialog.

- New shared `components/dialog/dialog-shell.component.ts`: backdrop, title
  row, scrolling body, footer slot, Escape and outside-click handling.
- `detail/project-rail.component.ts` loads the summaries (instructions,
  files, model, tools and skills, members, history) and opens the dialogs in
  `projects/components/*-dialog.component.ts`; a save flows back into the
  rail and the composer without a refetch.
- The project's own actions (rename, archive, restore, delete, leave) move
  to a menu on its name in the breadcrumb; the owner's "editors can manage
  members" switch moves into the Members dialog.
- Overview and Settings tab components are removed. Files, Members and
  Activity are dialog-native; Tasks is restyled as Recents.
- `/projects/:id/:tab` survives for links that predate the rail (the bell's
  `members`, Activity's `history`, the old `settings` → Instructions): it
  opens that dialog once, then settles on `overview`.
- Spec §7 records the as-built layout and shapes Phase 3.3 shared Outputs;
  docs-site pages updated.

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

* feat(projects): notify members when a task is shared, with an optional note

Sharing a task with a project can now tell people about it (shared-projects
2.5b, gap G6 from the team simulation). POST /conversations/{sid}/share with
accessLevel "project" accepts notify ({all: true} or {emails: [...]}, members
only, 400 naming anyone else) and a note of up to 280 characters. With no
notify nobody is told, and the sharer never is.

The fan-out reuses NotificationService.notify_many in a FastAPI background
task, so it never delays the share. The note rides the share row and the
SHARED_TASK# pointer (so a revoke falling back to an older share keeps that
share's note), shows under the task, and is in the notification. The
project.task_shared audit record gains notified (a count) and hasNote.

SPA: a "Let people know" section in the Share dialog (No one / Everyone /
Choose people from the project's members) plus the note, the bell renders the
new kind and opens the shared view, whose 404 now says the task is no longer
shared.

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

* feat: conversation archive write path for search (PR-2a)

Write each finished turn's user and assistant text to a new private,
unversioned conversation-archive bucket, the source the conversation
search index will be built from (docs/specs/conversation-search.md §4).

- CDK: ConversationArchiveConstruct with a lifecycle rule on
  conversations/ that follows CDK_CONVERSATION_RETENTION_DAYS (raw value;
  only Memory is clamped at 365); bucket name in SSM.
- Flag CONVERSATION_INDEX_ENABLED / CDK_CONVERSATION_INDEX_ENABLED,
  opt-in while in development. The runtime spends one env slot on it and
  resolves the bucket from SSM, since its 50-variable budget is at 47.
- Runtime: the stream coordinator queues a background put as the last
  step of a successful turn, after done; never awaited. Turns are keyed
  at their opening user message; original_message or the stored
  displayText is used so augmented prompts never reach the index.
- app-api: forks archive their copied turns under the forker; single and
  bulk session delete remove the session's archived turns regardless of
  the flag.
- Spec: PR-2 split into 2a/2b/2c; messages-route fallback moved to PR-3.

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

* test(projects): read the member list's name and email lines separately

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

* fix: back up and restore the conversation archive bucket

The backup-coverage guard requires every CDK data bucket in backup.py.
Once Memory's events expire the archive is the only copy of a turn's
text, and the search index is rebuilt from it, so it is user data:
added to backup.py (optional, like other later buckets) and restore.py's
BUCKET_SSM_MAP.

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

* feat(projects): let a project's assistant list and read shared tasks

Shared Projects 2.5c, gap G5 from the team simulation: counsel's escalation
summary found 2 of 7 because the rest lived in shared tasks the assistant
could not read.

A project harness gains shared_tasks_list and shared_task_read. They close
over the project and the invoking member, re-check membership on every call
(viewer and up, archived included), and refuse any share that isn't a project
share of this project with one message, so a guessed id reveals nothing.
shared_task_read returns the sharer's snapshot as a transcript (displayText,
assistant text, one line per tool call) in a <shared_task> data block whose
closing tag the text cannot forge, paged at PROJECTS_SHARED_TASK_READ_MAX_TOKENS
(6,000). The list is capped at 50.

The snapshot store moves to apis/shared/shares with a shared
load_snapshot_raw that ShareService now delegates to. The Runtime derives the
shared-conversations table and bucket names from PROJECT_PREFIX (its env is at
49 of 50) and gets GetItem on the table and GetObject on shares/* only.

The two specs are constants after the four memory tools in the harness's
cached toolConfig (~340 tokens), memoized like the memory tools, with no new
cache-key element; ordinary agents' tools are unchanged.

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

* fix(projects): tell a project's assistant to check shared tasks

On dev, after 2.5c, a plain "every open escalation" question in the
simulation project was answered from project memory alone: Haiku 4.5 never
called shared_tasks_list, although its description says to list shared
tasks before summarizing the team's status.

A harness's system text now carries a short "## Project Workspace" section
before "## Project Instructions": teammates' work lives in files, project
memory and shared tasks, and status questions check all three. It is the
same for every member and project, so it is part of the cacheable prefix
(73 tokens, written once per harness on deploy). Ordinary agents' text is
unchanged.

Measured with direct Converse calls on the simulation project's real
instructions, memory index and tool specs (6 runs per cell, a
shared_tasks_list call in the first response): the escalation question went
from 0/6 to 6/6, "where do we stand overall?" from 5/6 to 6/6.

Also records the 2.5b and 2.5c dev validation in the spec and the
simulation status.

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

* fix: keep injected long-term memory out of archived turns

Dev validation of #1463 found every archived user text began with a
<user_context>...</user_context> block. The runtime's long-term-memory
hook inserts retrieved records as the first text block of the turn's
user message on the in-memory agent.messages, which is what the archive
reads, so search would have matched users' stored preferences instead
of their words. Drop a user-message text block that is entirely that
wrapper; a user quoting the tag mid-sentence keeps their text.

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

* feat(kb-backend): reserve the conversations KB id and shape index documents

Shared groundwork for the conversation-search index consumer (#1380, PR-2b):

- kb_backend/reserved.py: the reserved app KB id `conversations`, whose
  KB_Record lives at AST#conversations / KB#conversations with no agent
  beside it. records.request_teardown (every agent teardown's choke point)
  and cleanup_orphaned_agent_rows.py refuse it, so neither can delete the
  shared search index as an orphan.
- provision_managed_kb gains extra_tags, added beside the tag-contract keys
  and never overriding them (used for purpose=conversation-search).
- conversation_archive: parse_archive_key and ArchivedTurn.from_json, plus
  index_documents.py — the conv#{session}#{index} document id, its text and
  its attributes, and the key/body agreement check that keeps one user's
  turn out of another user's filter.

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

* feat(conversation-index): consumer Lambda that syncs archived turns into the shared KB

The conversation-search index consumer (#1380, PR-2b). Drains SQS batches of
S3 Object Created / Object Deleted events from the conversation archive and,
for each distinct key, makes the index match the archive: object present ->
IngestKnowledgeBaseDocuments (IN_LINE text, re-ingest replaces), absent ->
DeleteKnowledgeBaseDocuments. Order- and redelivery-independent; re-checks
after ingest so a delete racing it still wins; 404 only means deleted.
Provisions the shared `conversations` KB lazily on first ingest and waits on
a concurrent provisioner instead of creating a second; never provisions to
delete. Partial batch responses; no-op with CONVERSATION_INDEX_ENABLED off.

Pipeline: backend/Dockerfile.conversation-index (pinned base, explicit COPY
closure), build-one.sh / deploy-image-lambda-one.sh cases, backend.yml
build + deploy jobs, nightly Trivy scan, supply-chain test registrations.

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

* feat(infra): conversation-index construct — archive events, queue and consumer

ConversationIndexConstruct (#1380, PR-2b): EventBridge delivery on the
conversation archive bucket; Object Created and Object Deleted rules on
`conversations/` keys, disabled unless conversationIndex.enabled; an SQS
queue (60-min visibility, 3 receives) with a 14-day DLQ; the consumer as an
arm64 DockerImageFunction on a byte-stable bootstrap stub, fed by an event
source mapping of batches of 10 at maximumConcurrency 2 with item failures.

IAM: GetObject on conversations/* (+ ListBucket so a missing object is a
404), Get/Put/UpdateItem on the AST#conversations partition only, a new
create-only managed-KB grant (no DeleteKnowledgeBase/DeleteDataSource) plus
direct ingestion. Function and DLQ join LambdaAlarmsConstruct; function name
published to SSM for the code deploy (SSM count guard 54 -> 55).

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

* docs(conversation-search): record the PR-2b consumer shape and decisions

Spec §4 gains the consumer's shape and why it uses a queue (batching keeps a
bulk delete inside per-account document quotas shared with agent uploads),
the sync-to-current-object rule, and what turning the index off leaves
behind. Provisioning now names the KB_Record, not an SSM kb-id, as the
pointer. §7 PR-2b row and §9 tests updated; CHANGELOG and the docs-site
flag row describe the consumer.

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

* feat(projects): members propose memory changes, editors review them (2.5a-1)

Shared Projects 2.5a, first half. A viewer can't save to project memory;
now any member can propose a change, and nothing changes until an editor or
the owner approves it.

Backend:
- PROPOSAL#{id} rows on the memory-spaces table (no GSI: one Query per
  space, capped at MEMORY_MAX_PENDING_PROPOSALS pending). A proposal is a
  whole file, checked like a save when proposed (save_entry's validation is
  now _prepare_save, shared), and records the file version it was written
  against.
- Approval claims the proposal (conditional pending -> approved), then saves
  with reason "proposal" and proposalId on FILEVER, and puts it back if the
  save fails. A proposal whose file has changed since is a 409 unless the
  reviewer sends an edited text. Approved new files join MEMORY.md.
- Proposals notify the owner and editors (project_proposal_pending);
  decisions notify the proposer (project_proposal_decided). The Runtime role
  may put INBOX#* rows on the projects table (dynamodb:LeadingKeys) and
  nothing else there.
- Routes under /projects/{id}/memory/proposals (list with stale, detail with
  the file's current text, approve, reject, withdraw), audit records for
  each, and a harness memory_propose tool (237 tokens, CountTokens) that
  memory_save's viewer refusal now points at.

SPA: a "Proposed memory changes" list above Recents, shown only when
something is pending. Reviewers see what each proposal adds and removes,
and approve, edit first, or decline with a note; a proposer can withdraw.
The bell and Activity render the new kinds and actions.

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

* test(projects): mock proposal API failures as HttpErrorResponse

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

* docs(conversation-search): make PR-4's isolation guarantees explicit requirements

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

* docs(projects): record 2.5a-1 and workspace-guidance dev validation

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

* feat(projects): item provenance, an archive with restore, and pins in project memory (2.5a-2)

Shared Projects 2.5a, second half.

- Provenance: a PROV#{slug} row per file maps each item's anchor to who
  added it and when, who last changed it, the task it came from, and the
  proposer and approver when it arrived through a proposal. Emails, never
  user ids. memory_save and memory_propose read the task's session from
  their tool context, so they stay memoized and their specs are unchanged.
- Archive: an item a save leaves out, or every item of a deleted file,
  becomes an ARCHIVE# row with its text and provenance, restorable for 365
  days in a project's shared memory and 30 in a personal space. The
  memory-spaces table gains a ttl attribute.
- Restore puts an item back at the end of its file (recreating a deleted
  file) through the save pipeline, keeping its anchor and provenance.
- Pins: pinned[] on the manifest entry. A save that would drop a pinned
  item is refused until it is unpinned.
- Routes under /projects/{id}/memory: files/{slug}, pins, archive,
  archive/{id}/restore, each with scope=project|mine.

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

* refactor(sessions): one delete cascade for both delete routes

SessionDeleteCascade names the five cleanups that follow a soft delete
(Memory events and summaries, files, share snapshots, artifact shares,
archived turns). The single and bulk routes queue it as background tasks;
the retention pruner will await it. Collaborators are passed in, so the
routes keep resolving them from their own module and their tests patch
the same names.

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

* feat(conversation-search): daily index reconciler and session retention pruner (PR-2c)

Archive/index reconciler: a second handler in the conversation-index image,
daily. Deletes archive objects past CONVERSATION_RETENTION_DAYS and KB
documents with no archive object, whatever CONVERSATION_INDEX_ENABLED says.
Lists the KB before the archive, ten documents a call at one call a second,
at most 2,000 a run, and aborts on any listing failure or an empty archive
beside a non-empty index. Own bootstrap stub so the consumer's digest holds.

Session pruner: a daily Fargate task on app-api's task definition family
(latest revision), running the shared delete cascade on sessions whose
lastMessageAt is past retention. Skips continuable turns, pending
interrupts and rows without lastMessageAt; re-checks before each delete;
oldest first, 500 a run, a second apart. Deletes only when
CONVERSATION_RETENTION_PRUNE_ARMED is set and a dry run for the same
retention is recorded in SSM, so the first run in an environment is dry.
CONVERSATION_RETENTION_PRUNES_SESSIONS (default on) turns it off.

Refs #1380

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

* feat(conversation-search): one-off prune of sessions past the 90-day cliff

backend/scripts/prune_sessions_without_events.py deletes sessions older
than 90 days whose Memory events and archive objects are both verifiably
gone, through SessionDeleteCascade with the retention pruner's guards.
Dry-run by default; a canary over the five newest sessions stops the run
if Memory shows no events anywhere; each candidate is re-checked before
its delete; --user, --limit, --sleep, --out. Loads app-api's environment
from the deployed task definition.

Records the decisions in spec §10: pruning defaults as built in PR-2c,
and a one-off script for the cliff.

Refs #1380

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

* docs(kaizen): queue the prod 90-day-cliff prune and pruner arming

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

* fix(conversation-search): reconciler pages KB documents by 100 and skips NOT_FOUND

Found on the reconciler's first dev run. A managed knowledge base rejects
ListKnowledgeBaseDocuments maxResults above 100 (the SDK model says 1,000),
so every run aborted at the listing; the brake held and nothing was
deleted. And a deleted document stays in the listing as NOT_FOUND, which
would have been re-deleted and counted as an orphan every day. NOT_FOUND
is now counted as already deleted.

Refs #1380

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

* fix(projects): say only a person can unpin a memory item

On dev the assistant, refused for dropping a pinned item, offered to unpin
it itself although it has no tool to. The refusal now says a person has to
(an editor, for project memory). Also records the 2.5a-2 dev validation.

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

* feat(conversation-search): serve expired conversations from the archive

When AgentCore Memory returns no events for a session, GET
/sessions/{id}/messages reads the session's archived turns instead:
text only, at their original message ids. Sessions with live events
never read the archive. No IAM change; app-api already had GetObject.

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

* feat(conversation-search): archive backfill script (PR-3)

backfill_conversation_archive.py copies every live session's Memory
history into the conversation archive, byte-identical to the runtime's
objects. Dry-run by default, --confirm-prefix for --apply, conditional
puts, oldest first, throttled with --sleep.

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

* docs(kaizen): the cliff prune needs a second run — existing events kept their 90-day clock

Measured on dev 2026-10-07; records the dev rehearsal.

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

* feat(conversation-search): offer archive-served conversations read-only

A conversation whose Memory events expired opens from the conversation
archive (PR-3), but the agent restores from Memory, so a new turn there
starts with no context and its archive write replaces the archived first
turn. The messages response now carries `fromArchive: true` on pages the
archive served, and the SPA replaces the composer with a notice and a
"Start a new conversation" button. Nothing changes on the turn path.

Decides docs/specs/conversation-search.md §10 q4 (#1380).

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

* feat(projects): share artifacts with a project as its Outputs (3.3)

Shared Projects 3.3. Until now a project surfaced shared tasks only: an
artifact made in a project task stayed in its author's library.

- Artifact shares gain access_level "project". The project is the one the
  artifact's task belongs to (its session's preferences.projectId), so only
  artifacts made in a project task can go to one; the read check is project
  membership at read time, so a removed member loses the output.
- An OUTPUT#{artifactId} pointer on the project lists it, one per artifact,
  newest version wins. It is rebuilt after every create, update, revoke,
  artifact or session delete and rename; a create whose pointer can't be
  written is rolled back.
- GET /projects/{id}/outputs lists them; DELETE …/outputs/{artifactId}
  revokes the project share (the sharer always, an editor while active).
  Audited as project.output_shared / output_removed.
- SPA: the artifact share dialog offers "Project members" first when the
  artifact can go to a project (GET …/shares now says which), and the
  project page shows an Outputs row of cards between the composer and
  Recents, each opening the shared-artifact view.

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

* feat(conversation-search): --archive-without-index for the prod backfill rescue

Raising Memory's event expiry did not extend events already written
(measured on dev 2026-10-07), so the backfill is the only rescue for
pre-#1450 prod sessions still inside their 90 days. Prod has
CONVERSATION_INDEX_ENABLED off and --apply refused to run there. The new
flag lets --apply write the archive (which the messages route reads
whatever the flag says) without indexing it.

Also records the q1 answer in the spec and queues the urgent prod run in
the kaizen review queue with the dev run's counts.

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

* fix(projects): an output card's accessible name says who shared it once

Validating #1477 on dev, the Outputs card read "…, shared by Shared by
you" to a screen reader. Also records the 3.3 dev validation in the spec.

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

* fix(conversation-search): put the user in the index document id

The KB document id was conv#{session_id}#{message_index}, but session
ids are not unique across users. On dev two users' probe sessions share
ids, so their turns mapped to one document: a consumer batch holding
both was refused as duplicates (18 documents during the PR-3 backfill),
in separate batches one replaced the other, and deleting either session
deleted both. The id is now conv#{user_id}#{session_id}#{message_index}.

The reconciler deletes old-format documents as orphans, and
reindex_conversation_archive.py re-queues every archived turn to the
consumer's queue so existing turns are ingested under the new id
without touching the archive.

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

* feat(projects): Memory tab, browse and govern (2.8a)

Shared Projects 2.8a, the first of three Memory tab PRs. A project's
memory could only be read by asking the assistant; now it has a page.

- /projects/:id/memory, opened from a Memory row in the settings rail
  (with the shared memory's file count). Project and "Just me" scopes,
  each listing its index (MEMORY.md) first, then its files with a size
  meter, updated date and item count. Where you are rides the query
  string (scope, file, view), so links and the bell land on one file.
- File view: each item with its provenance written underneath (inline,
  not on hover), [[links]] as chips, pin toggles for whoever can edit
  the scope. The caller's own task links back; others' stay private.
- Review queue moved off the project page into the Memory page, with
  current and proposed side by side; the project page keeps a one-line
  pointer. Approval is per proposal (a proposal is a whole file).
- Archive view with a file filter and Restore.
- GET /projects/{id}/memory gains `limits` so meters follow the
  deployment's MEMORY_FILE_* settings; the archive read gains `people`.

Editing, the link picker and Propose are 2.8b; history is 2.8c.

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

* feat(conversation-search): search route with isolation-enforced text leg (PR-4)

GET /sessions/search on app-api (docs/specs/conversation-search.md §5):
a lexical leg over the caller's own session rows (titleLower and
firstPrompt) and a full-text leg over the shared conversations KB,
merged one row per conversation.

- One function, search_conversation_index, is the only Retrieve caller
  for the conversations KB. Its required user id comes from the session
  cookie; it builds the user_id equals filter itself and takes no filter
  from its caller. An architecture test enforces the single caller.
- Every hit is checked against the caller and joined to the caller's
  own session row; a missing or deleted row drops it.
- Retention belt with lenient created_at parsing, a per-user token
  bucket (20 full-text searches a minute), lexical fallback when the
  index is off, unprovisioned or failing.
- titleLower/firstPrompt ride on the title write; backfill script for
  titleLower.
- CONVERSATION_SEARCH_ENABLED (in development, off), CDK config and
  platform.yml variable; app-api also gets CONVERSATION_RETENTION_DAYS.
  Nothing on the AgentCore Runtime changes.

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

* feat(conversation-search): Cmd/Ctrl+K search dialog, Conversations scope (PR-4)

The search dialog on CDK Dialog (docs/specs/conversation-search.md §6):
Recent + Actions empty state, loaded-title filter at once, title and
opening-prompt matches debounced, full text on Enter or a pause with a
highlighted snippet. Combobox keyboard model with aria-activedescendant.
Cmd/Ctrl+K on the app shell, a search button beside New Session, and
Enter in the sidebar filter hand off to it. Results open at the matching
turn via ?m=. Lazy-loaded, behind features.conversationSearch (off in
every environment).

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

* docs(conversation-search): PR-4 as built, flag rows, changelog

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

* fix(conversation-search): read the user-scoped index document id

The index document id now names the user (conv#{user_id}#{session_id}#
{message_index}), because session ids are not unique across users. The
search drops a hit unless the id's user and the user_id attribute are
both the caller's, before the session-row join. Legacy two-part ids are
never hits. Tests cover a forged id, two users sharing a session id
(through the real route, with and without the filter) and a legacy id.

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

* chore(conversation-search): turn search on in the dev build

Dev's archive is backfilled and indexed under the user-scoped document
id, so the dev SPA offers the search dialog; the backend side is the
development environment's CDK_CONVERSATION_SEARCH_ENABLED. Prod stays off.

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

* feat(ios): scaffold the iOS companion app

Add ios/ as a third client beside the SPA and the TUI, structure-only so the
hygiene and gates land before any product code: a thin SwiftUI app target over
a local AgentCoreKit Swift package (tools 6.2, Swift 6, strict concurrency,
warnings as errors), every build setting in Config/*.xcconfig with signing and
bundle id in an untracked Local.xcconfig, MARKETING_VERSION generated from the
repo-root VERSION file, an empty privacy manifest, swift-format lint, and a
README that records the conventions and what is deliberately left out.

Wire it into the repo the way tui/ is: sync-version.sh writes
ios/Config/Version.xcconfig (pre-release suffix dropped, since
CFBundleShortVersionString must be dotted numbers); the CI path classifier
gains an `ios` output pinned by test_ci_path_filter.py; tests.yml gains a
path-gated test-ios job on the pinned xcode-27 runner that lints and runs
xcodebuild test unsigned in the simulator.

Build and test through AgentCore.xcworkspace, never the project: a local
package's test target is only a testable when the package is a workspace
member, and `-project` reports "no test bundles available".

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

* fix(conversation-search): backfill holds session ids Memory cannot address

A session row whose id fails Memory's sessionId pattern (a probe row on
dev, `bad id'--`) made ListEvents raise a ValidationException, counted
as a failed session and turned every run's exit code red. Such an id can
hold no events, so the backfill now holds it before reading.

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

* feat(projects): Memory tab editor, link picker and Propose (2.8b)

Shared Projects 2.8b. Members can now change memory on the Memory page
instead of only through the assistant.

- Backend: §5's structured PUT /projects/{id}/memory/files/{slug}
  (items with the anchors they were read with, description, aliases,
  baseVersion; a stale or taken base is a 409), DELETE of a file and
  PUT …/memory/index, all project-scoped. A new file joins its scope's
  index; a deleted file's own index line now leaves it. Shared-memory
  edits and deletes are audited (project.memory_edited/_deleted), with
  Activity lines.
- SPA: a block editor (one box per item, description and aliases as
  fields, pinned items kept), reordered by drag or keyboard (a roving
  tabindex over the handles, Alt+arrow moves, announced live), with
  live validation tied to each field and the server's sentence on a
  refused save. A link picker dialog (combobox) inserts [[name]] at the
  caret. Viewers propose through the same editor. New file, Delete,
  Edit index, and "Just me" can be started from the page.

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

* fix(projects): legible link chips and pin labels on the Memory page in dark mode

Axe on dev (2.8a) found the [[link]] chip at 3.74:1 in dark mode:
primary-accessible-dark is tuned for gray-900/950, and the Memory page's
panels are gray-800. primary-50 measures clean there.

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

* feat(projects): render inline bold, emphasis and code in memory items

On dev, the assistant's memory items read as raw asterisks
("**Fixed price:** $184,500"). The Memory page now renders the inline
markdown it writes; anything else stays literal.

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

* fix(projects): a file recreated by an archive restore rejoins the index

Delete now drops a file's index line, so restoring one of its items from
the archive, which recreates the file, adds the line back. Found
restoring a deleted file's item on dev.

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

* docs(projects): record 2.8a's dev validation

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

* fix delete tool bug

* docs(specs): draft native app authentication for the iOS companion app

Design for how a native client signs in and reaches the same routes the
SPA uses: a device session, which is the existing BFF SessionRecord
carried as `Authorization: Bearer <sealed blob>` instead of a cookie.
Login runs the existing authorization-code + PKCE flow inside
ASWebAuthenticationSession with the existing confidential app client; the
callback hands the session over through a one-time handoff code exchanged
with an app-side PKCE verifier, so the sealed token never appears in a
URL. Device sessions do not slide and live to the 30-day absolute limit
or revocation. inference-api, the Runtime authorizer and the Gateway are
untouched. Gated by NATIVE_AUTH_ENABLED, default off.

Records the rejected alternatives (Cognito tokens on the device, API
keys, device-authorization grant), the cost and security analysis, a
three-PR breakdown and the open questions.

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

* feat(projects): Memory tab history with diff and restore (2.8c)

Shared Projects 2.8c, the last Memory tab PR.

- History view on the Memory page (view=history): versions newest first
  with why and who, the selected one compared item by item with the
  file now, and Restore for whoever can edit the scope.
- POST /projects/{id}/memory/history/restore {slug, version} saves that
  version's items, description and aliases as a new version (reason
  restore). Items keep their old anchors; one that left the file since
  comes back with the provenance its archive row ke…
…evelop-1.27.0

# Conflicts:
#	CHANGELOG.md
#	ios/Config/Version.xcconfig
@philmerrell
philmerrell merged commit adfeb2f into develop Oct 9, 2026
10 checks passed
@philmerrell
philmerrell deleted the backmerge/main-into-develop-1.27.0 branch October 9, 2026 04:08
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