Chat thread folders and grouping proposals¶
This document specifies Milestone 29. The engineering plan states the requirement; this document states the mechanism. It is subordinate to engineering-plan.md and it reuses rather than replaces the session index, the persona nomination lifecycle, the memory extractor's provider discipline, and the http-api-and-streaming conventions. ADR-0102 records the architectural decisions and the owner's authorization, and ADR-0110 amends it to admit the typed-judgment port and the judgment matcher.
A chat conversation is a sessions row shared by every surface, and every
client lists those rows as one flat, activity-ordered history
(http-api-and-streaming.md:805-809). Nothing groups them. The owner wants
conversations organized into folders, wants the assistant to notice when a
handful of unfiled conversations belong together and to say so, and wants the
folders to carry the names the owner gives them. Milestone 29 builds it: flat,
single-parent, principal-scoped folders as server-owned session state; a
proposal loop in which a maintenance pass nominates a grouping and only
the owner's explicit acceptance files anything; and the surfaces that show,
rename, move, and review — nine HTTP routes behind a default-off flag and a
native sidebar that degrades to today's flat list when the server has none.
Milestone 29 is authorized as a parallel workstream. Its gates may become green independently, but the verified gate ceiling advances only in numerical order.
Scope¶
Milestone 29 delivers the folders, the proposals, the grouping computation, and the surfaces that operate them.
- Folders. A named, principal-scoped container; a chat session belongs to at most one folder, and a folder holds any number of sessions. Create, rename, delete, and move are owner acts.
- Proposals. A maintenance pass proposes either a new folder over at least a configured number of unfiled chat sessions, or adding unfiled sessions to a folder that already exists. The owner accepts or declines; a decline is durable; a proposal whose members change before review withdraws itself.
- Grouping. A model-assisted grouper under a strict schema, a byte and cost budget, and a grounding check, with a deterministic lexical grouper as the fallback the pass can always fall back to. An optional judgment matcher (ADR-0110) decides which existing folder an unfiled conversation belongs in through the typed-judgment port; it is off by default, wraps the other two, and returns their result unchanged on any failure.
- Surfaces. Nine routes under
/v1/foldersand/v1/sessions/{id}/folder, an additivefolder_idonSessionView, and the native sidebar's folder sections, move menu, name sheet, and suggested-folder rows.
Seven things are out of scope, and each is named because a reader who does not find it here should find the reason here.
- Nested or shared folders. One level, one owner. Hierarchy and sharing each need a design of their own and nothing in the owner's request asks for either.
- Email-mode organization. Draft-only email sessions remain organized by their thread and account. An email discussion opened in Chat may be filed manually under ADR-0184, using the owner-interaction evidence specified below. Email-linked sessions remain excluded from grouping proposals; scheduled, operational and delegated sessions remain excluded entirely.
- Automatic filing. No threshold, similarity score, or model confidence files a conversation without the owner's acceptance. The pass proposes; the owner disposes. This is the persona surface's rule (persona-surface.md:60-63) applied to a lighter object.
- Embeddings and a vector extension. Roadmap item B6 keeps the semantic
arm and
pgvectordeferred behind benchmark evidence, and ADR-0045 recorded the no-embedding decision. Grouping here is a bounded structured model call over a few dozen titles, falling back to token overlap. The judgment matcher changes none of this: it asks a closed question about one conversation at a time and stores no vector. - Folder text in a conversation prompt. Folder names and proposal rationale never render in the frozen prefix, the runtime-metadata row, or recall. They are organization, not instruction.
- Search over folders or conversations. The sidebar groups; it does not search. A search surface is a separate request.
- Terminal-client work. The Python client lists no history today and email-experience.md keeps it that way; folders are a native concern.
Foundations this design reuses¶
The session index is authoritative and principal-scoped. ADR-0050 made
conversation history server state and every local history store a cache
(multi-device-and-surfaces.md:49-53). A folder is a fact about a session the
same way its title is: SessionView.title is server-owned and never a
client-only value (http-api-and-streaming.md:667-677). Membership therefore
lives beside the session on the server and every client reads it from the
index, which is what lets a folder created on the Mac appear on the iPhone.
A session is not owned by a channel, and metadata is not a column. The surface seam records the surface in session metadata precisely because a session column would be wrong (inbound-surfaces.md:382-385). Folder membership is the opposite case — it is owner-organized state with a uniqueness rule, a cascade, and a count — so it is a table, not a metadata key. Metadata stays opaque, bounded, and untrusted.
Session metadata never enters a model prompt. The rule is a security rule (http-api-and-streaming.md:712-720): metadata is client input and the shape an injection takes. The grouping call reads titles, bounded message text, and folder names — content the platform already treats as user-authored data under a trust label — and reads no metadata key at all.
The persona nomination lifecycle. Milestone 22 defined the shape of an assistant proposal the owner resolves: closed eligibility (persona-surface.md:173-180), a bounded open set (persona-surface.md:182-184), a decline that is durable and content-keyed, and withdrawal when the source dies before review (persona-surface.md:200-210). Proposals here take that lifecycle unchanged and add a second withdrawal cause, so a reason field exists where the persona design deliberately omitted one.
The extractor's provider discipline. Model-assisted memory formation computes the deterministic result first, calls the provider under a strict response schema and a fixed budget, grounds every returned item against its source, and records an audited deterministic fallback on any failure (memory-formation-and-consolidation.md:752). The grouper is that design with a different schema.
The maintenance role. Background work runs as sweeps in the maintenance worker under an advisory lock (runtime-loop.md:1519), each on its own timer when it is slow. The proposal pass is one more sweep.
The domain model¶
Three records, all tenant- and principal-scoped as repository predicates in both stores under one contract suite.
class ThreadFolder(BaseModel):
id: UUID
tenant_id: str
principal_id: str
name: str # normalized display name, 1–64 characters
created_at: AwareDatetime
updated_at: AwareDatetime
Names are normalized and unique per principal. Normalization collapses
whitespace, applies NFC, and refuses an empty result, more than 64 characters
(the session title's cap, so the sidebar's two columns share one width),
control characters, and any value failing the secret-material or
injection-pattern scan; a refusal is malformed_request. Uniqueness is on
the case-folded name, so Travel and travel are one folder and a second
create is conflict with details.reason = "folder_name_taken". A principal
holds at most 200 folders; the 201st is conflict with reason
folder_limit.
Membership is a row keyed by the session.
session_folder_memberships (
session_id uuid primary key references sessions(id) on delete cascade,
folder_id uuid not null references thread_folders(id) on delete cascade,
tenant_id text not null,
principal_id text not null,
added_at timestamptz not null
)
The primary key is the single-parent rule: a session is in at most one
folder, and a move is an upsert. The first cascade is how ADR-0050's session
deletion (http-api-and-streaming.md:811-817) leaves no membership behind
without a new step in the deletion transaction; the second is how deleting a
folder returns its conversations to the unfiled state in the same statement.
The denormalized tenant and principal let ownership be a predicate on this
table without a join. The sessions table, its domain object, its repository
port, and its list ordering are untouched; the list route fills
SessionView.folder_id from one batched membership lookup per page, the same
way it already fills last_run_id.
class FolderProposal(BaseModel):
id: UUID
tenant_id: str
principal_id: str
kind: Literal["new_folder", "add_to_folder"]
proposed_name: str | None # new_folder only
target_folder_id: UUID | None # add_to_folder only; no foreign key
member_session_ids: tuple[UUID, ...] # sorted, unique, 1–12
rationale: str | None # at most 200 characters, hazard-scanned
derivation: Literal["lexical", "model"]
content_key: str # SHA-256 over kind, target, sorted members
state: Literal["proposed", "accepted", "declined", "withdrawn"]
withdrawal_reason: Literal["member_gone", "target_gone", "name_taken"] | None
resulting_folder_id: UUID | None # set by acceptance
created_at: AwareDatetime
resolved_at: AwareDatetime | None
A proposal is self-contained: it copies the member identifiers and the name
it proposes, so it outlives a folder's later deletion without dangling, which
is why target_folder_id carries no foreign key and withdrawal, not the
database, resolves a proposal whose target disappears. The model validates
its own consistency — a new-folder proposal carries a name and no target, an
add-to-folder proposal the reverse; a resolved proposal carries resolved_at
and an open one does not; a reason appears only on a withdrawn row and a
resulting folder only on an accepted one; the content key equals the hash of
its own fields. A partial unique index over (tenant_id, principal_id,
content_key) where state = 'proposed' makes the same grouping open at most
once, the same guard the persona nomination table uses.
Manual operations¶
Every manual operation is one unit of work, principal-first, and records one process event with a stable derivation key and a content-free payload.
- Create normalizes the name, checks the count, inserts, records
folder.created. - Rename normalizes, refuses a collision with a different folder's key
(renaming
traveltoTravelis allowed), updatesupdated_at, recordsfolder.renamed. Membership is untouched; a rename never moves anything. - Delete removes the folder and, by cascade, its memberships; the
conversations are unfiled, never deleted. Open add-to-folder proposals
naming the folder withdraw in the same transaction with reason
target_gone. Recordsfolder.deleted. - Move takes a session and a folder identifier or null. The session must
be owned and must be a chat session — its metadata carries none of
email_operational,schedule_id, orrun_kind. A session carryingemail_thread_idadditionally requires an existingemail.discussion.openedoruser.message.createdevent whose actor type isprincipaland actor identifier is the session owner (ADR-0184). Otherwise the request isconflictwithdetails.reason = "session_not_chat". The folder, when given, must be owned. Moving to the folder a session is already in, or unfiling an unfiled session, is a 200 with no event. A real change upserts or deletes the membership row, withdraws every open proposal that names the session (reasonmember_gone; the pass will re-derive the remainder), and recordssession.folder.changed.
A missing, cross-tenant, or differently owned folder or session is a 404 indistinguishable from absence, the rule every principal-scoped route in http-api-and-streaming.md applies.
Proposals¶
Eligibility is closed. Only the maintenance pass proposes; no route, tool
call, or model output creates a proposal. A session is a candidate only when
all of the following hold: its metadata carries none of email_thread_id,
email_operational, schedule_id, or run_kind, even after an owner opens an
email discussion (ADR-0184); it is listed by the ordinary session index;
it has a title; it is unfiled; and it is not already named by an open
proposal. A new-folder proposal requires at least threshold members
(shipped: 4) and at most max_members (shipped: 12); an add-to-folder
proposal requires at least one member and an existing, owned target folder.
A candidate set below the threshold is not a proposal, however confident the
grouper.
The set is bounded. At most max_open proposals (shipped: 3) may be
outstanding per principal. The pass computes nothing when the set is full,
and takes at most the free slots when it is not. Review must stay a minute's
work, or it will not happen and the sidebar fills with suggestions nobody
reads. Proposals are disjoint: no session is named by two open proposals.
Decline is durable, and content-keyed. A declined or accepted proposal's content key is never proposed again — not by the next pass, not by a provider change, not by a fallback run. Because a grouping re-derives with new identifiers every pass, the key is the grouping itself: the kind, the target, and the sorted member set. The pass also refuses a candidate whose member set is a near duplicate of a declined proposal's of the same kind and target — Jaccard similarity of the two member sets at or above 0.8 — so one new arrival joining a declined group does not resurrect it, while a group that has materially changed may be offered once more.
Withdrawal happens when a member is deleted, moved, or otherwise filed before review, when an add-to-folder target is deleted, or when a new-folder name has since been taken by an owner-created folder. A withdrawn proposal frees its slot, its members become candidates again, and the withdrawn state with its reason is the record. Withdrawal is done by the operation that caused it — a move, a delete, an acceptance of another proposal — in that operation's transaction, and again defensively at the start of every pass.
Acceptance is one unit of work. An accepted proposal replays
idempotently; a declined or withdrawn one is conflict with reason
proposal_resolved. The service re-reads every member and keeps those that
are still owned, chat, and unfiled; if none remain it withdraws the proposal
with reason member_gone and answers conflict with reason
proposal_stale. For a new folder it creates one from the proposed name or
the owner's optional override in the request body; a name collision is
conflict with reason folder_name_taken and the owner retries with a
different name. For an add-to-folder proposal it re-reads the target and, if
the folder is gone, withdraws with reason target_gone. It then files the
surviving members, withdraws any other open proposal that named one of them,
and resolves the proposal last, under a guarded transition only a still-open
row can take, so a rolled-back acceptance leaves folder, memberships, and
proposal untouched together. Records folder.created when a folder was made
and folder.proposal.accepted with the counts of members filed and skipped.
Decline is a single guarded transition to declined and one
folder.proposal.declined event. Declining a declined proposal is a 200;
declining an accepted or withdrawn one is conflict.
The grouping computation¶
The grouper answers one question for one principal: given the unfiled chat sessions and the folders that exist, which sessions belong together, under what name, and which belong in a folder that already exists?
Inputs. For each candidate session: its identifier, its title, and a
snippet — the first 400 characters of the principal's first
user.message.created text in that session, read from the event log. For
each existing folder: its identifier, its name, and up to five member titles.
The threshold. Nothing else: no metadata key, no assistant or tool text, no
timestamp, no prior proposal, no memory. At most 200 candidates enter a pass;
the index is read in its own order and the first 200 eligible sessions are
the pass's universe.
The lexical grouper is the floor. It is deterministic, runs on every
pass, and is the whole answer whenever the model policy is non-routed or the
model call fails. Each session's terms are the lexical tokens of its title and
snippet — the same tokenizer memory retrieval uses — minus a stop list,
tokens shorter than three characters, and all-digit tokens. Two sessions are
linked when they share at least two terms and the Jaccard similarity of their
term sets is at or above similarity_threshold (shipped: 0.2). Connected
components of at least threshold sessions are new-folder candidates, named
from their two most document-frequent terms; a component larger than
max_members keeps its first max_members sessions in index order. Each
existing folder's term profile is the union of its members' terms, and an
unfiled session joins an add-to-folder candidate for that folder when it
shares at least two terms with the profile and their overlap coefficient is
at or above the same threshold. A new-folder candidate whose name key
collides with an existing folder becomes an add-to-folder candidate for it.
Output order is fixed — new folders first, larger first, then by lowest
member identifier — so the same input yields the same proposals.
The model-assisted grouper refines it. When the configured policy is routed, the pass resolves a structured-output model and sends the inputs as one JSON document: a platform-trust system instruction and a user-trust message carrying the encoded inputs, with the principal on the label. The response schema is closed — a list of at most sixteen groups, each with a name, a member list, an optional target folder, and a short rationale, every field required — and the provider is asked for exactly one final document and no tool call. Budgets are fixed module constants rather than knobs: 32 KiB of encoded input, 8,192 input tokens, 2,048 output tokens, a cost ceiling of USD 0.10 per call, and a thirty-second deadline; an over-budget call is a failure, not a partial success.
Grounding is local and total. Every returned group is checked against the pass's own inputs before it becomes anything: every member identifier must name a candidate session, no session may appear in two groups (the later group is dropped), a target must name an existing folder or be null, a new-folder group must reach the threshold, the name must pass the folder name normalization and scans, a new-folder name whose key collides with an existing folder is converted to an add-to-folder group for it, and a rationale failing the scans is dropped while its group survives. The surviving model groups come first; lexical groups disjoint from them are appended. Any exception on the model path — resolution, transport, timeout, budget, schema, parse — records an audit with the error class and the pass proceeds with the lexical result alone.
The judgment matcher decides add-to-folder, and only that. When
judgment_matching_enabled is true and a judgment provider is composed, a
matcher wraps whichever grouper the pass would otherwise use. It runs the
inner grouper first and keeps its result, then asks the
typed-judgment port one question per unfiled
conversation: a Choice whose options are the existing folders and one explicit
no-match option. Each folder option is described by the folder's name with up
to five member titles as examples; option keys are opaque — f0, f1, and so
on over folders ordered by name key and then identifier, plus none — and the
key-to-folder mapping never leaves the process. More than twelve folders
become parallel questions in the same request, up to forty-eight folders;
beyond that the folders are first narrowed deterministically by the lexical
overlap score. A match is accepted only when the chosen option is not none
and its probability is at or above judgment_match_threshold; across
parallel questions the highest accepted probability wins, ties broken by name
key and then identifier. Accepted conversations are grouped per folder into
add-to-folder candidates that come first in the output, and a conversation
the matcher placed is removed from the inner grouper's add-to-folder
candidates. New-folder candidates are untouched, because a judgment provider
names nothing. The pass still re-applies every eligibility rule to whatever
comes back.
The matcher's budgets are fixed module constants, as the model-assisted grouper's are: at most sixty-four conversations per pass in index order, eight concurrent requests, a twenty-second deadline, 16 KiB per encoded request, and a cost ceiling of USD 0.01 per pass. An encoded request over its ceiling is rebuilt without the example titles, and one still over it is a budget breach. The first request runs alone and the rest fan out only if it succeeds, so an outage or a refused credential costs one call. Any provider error, deadline, or budget breach cancels the outstanding requests, discards every judgment, and returns the inner grouper's candidates unchanged with the matcher's fallback flag set; accumulated usage is kept for the audit. Conversations beyond the cap keep their inner placement.
Injection defense on the way in. A title, snippet, or folder name that
matches the injection-pattern scan is replaced by [BLOCKED] in the encoded
input, the substitution the memory extractor makes for existing beliefs, and
a snippet that matches the secret-material scan is dropped so that the title
alone represents its session. The system instruction says the inputs are data
to group and never instructions to follow. The judgment matcher is stricter,
because a placeholder is noise to a classifier: a conversation whose title
matches either scan is not judged at all, a snippet or member title that
matches either scan is omitted, a folder whose name matches either scan is not
offered, and titles are cut to 256 characters. Instructions and option
descriptions are platform-authored; conversation text travels only in named
state fields.
Audit. Every pass records one folder.proposal.pass event carrying the
provider and model resolved, token usage, cost, whether the fallback was
used, the error class if any, and counts of candidates, proposals created,
and proposals withdrawn — never a title, snippet, name, or rationale. The
event always carries eight further fields for the matcher —
judgment_provider, judgment_model, judgment_requests,
judgment_matched, judgment_input_tokens, judgment_cost,
judgment_fallback_used, and judgment_error_class — which read none, zero,
or false when the matcher did not run, and which are likewise content-free. The
error class is the judgment provider's reason code, or the error's class name
for a deadline or a budget breach. The pass also writes one
folder_proposal_pass log line with the pass's own content-free fields — the
event's tenant and principal identifiers are left out — so an operator can
read a pass from the service log without a database query. The line names the
created count proposals_created, because created is reserved on a log
record.
The maintenance pass¶
The pass runs in the maintenance worker as a sweep on its own timer, every
interval_seconds (shipped: 900), for the composed principal, and only when
AGENT_THREAD_FOLDERS_API_ENABLED is set and proposals.enabled is true.
It is idempotent under retry: the partial unique index refuses a duplicate
open grouping, a conflict on insert is skipped rather than raised, and
every proposal it creates is content-keyed.
In order: withdraw stale open proposals and count the rest; stop when the set
is full; read candidates and folders; read snippets; group; post-filter for
disjointness, size, declined keys, and near duplicates of declined proposals;
take at most the free slots in the grouper's order; insert each and record
folder.proposal.created; record the pass audit. The pass trusts nothing the
grouper returns: the post-filter re-applies every eligibility rule, so a
grouper defect can produce a bad suggestion but never an ineligible proposal.
The pass is not user-facing scheduling; it is internal maintenance, in the same process and under the same lock as memory consolidation.
The routes¶
Nine routes, mounted only when AGENT_THREAD_FOLDERS_API_ENABLED is set —
default off, like every optional surface. They require the existing
session.read and session.write scopes rather than a new pair, because a
folder is a view over sessions the principal already owns: nothing here
grants authority a session-writing client does not already hold, and a new
scope would force every deployment's token grant and the native client's
credential to change for no security gain. They use the same authentication
middleware, principal-first application signatures, request-id header, error
envelope, and cross-principal not-found rule as every route in
http-api-and-streaming.md; that document carries
a stub subsection pointing here, and this document owns the schemas. Every
success response carries Cache-Control: private, no-store.
POST /v1/folders session.write 201
GET /v1/folders session.read 200
GET /v1/folders/proposals?state=proposed session.read 200
POST /v1/folders/proposals/{proposal_id}/accept session.write 200
POST /v1/folders/proposals/{proposal_id}/decline session.write 200
GET /v1/folders/{folder_id} session.read 200
PATCH /v1/folders/{folder_id} session.write 200
DELETE /v1/folders/{folder_id} session.write 204
PUT /v1/sessions/{session_id}/folder session.write 200
POST /v1/folderstakes{"name"}and returns aFolderView— identifier, name, thread count, timestamps.PATCHtakes the same body and renames.DELETEreturns 204 once the folder and its memberships are gone.GET /v1/foldersreturns every folder in one page ordered by case-folded name; two hundred is the cap, so there is no cursor.GET /v1/folders/proposalsfilters by state and defaults to the open ones, newest first. EachFolderProposalViewcarries the kind, proposed name, target, member identifiers, rationale, derivation, state, reason, resulting folder, and timestamps — no tenant, no principal, no store internals.- Accept takes an optional
{"name"}override and returns the resolved proposal; decline returns it likewise. Both on a foreign or unknown id are 404; both on a resolved proposal behave as the proposal section states. PUT /v1/sessions/{session_id}/foldertakes{"folder_id"}, a UUID or an explicit null, and returns the session'sSessionViewwith its newfolder_id. The key is required; an absent key ismalformed_request, so a client cannot unfile a conversation by forgetting a field.SessionViewgainsfolder_id, null when unfiled, on every route that returns it, whether or not the flag is set; with the flag off it reports memberships that exist and no route can change them.
The proposal routes are registered before the folder-identifier routes so
proposals is never parsed as an identifier. Request bodies forbid unknown
fields.
The native experience¶
The Apple client's sidebar keeps mirroring the server's authoritative index
under the contract in apple-client.md: the cache gains
the server-assigned folder_id beside the title, the server's value wins on
every reconciliation, and the list of folders and open proposals is fetched
with the history and held in memory. On macOS, iPhone, and iPad, the sidebar
first offers a collapsible Recent chats section below New conversation: up to
five non-scheduled conversations in activity order across all folders and
unfiled history. These shortcuts open the existing conversation and leave its
folder and history placement intact. The section starts expanded, remembers
its toggle on the device independently of folder expansion and solo mode,
works without the folder surface, and is hidden when no chats exist. This
owner-requested discoverability improvement adds no server state or gate.
The sidebar then renders a suggested-folders section with one row per open proposal — the proposed name
or target, each member title resolved from the cached history on a line of
its own, and accept and decline controls, plus for a new folder a rename
control that accepts it under the owner's name through the route's optional
override — then one section holding the folders, each a row that expands to
its conversations and whose context menu renames or deletes it or switches
solo mode, then the flat history of unfiled conversations, then a new-folder
control. Which folders are expanded is device state, remembered across
launches and never sent to the server, and only the owner's own toggles
change it: a proposal, a refresh, or a new folder never reopens a closed one.
In solo mode, the default, at most one folder is open and opening one closes
the other; with solo off, every folder is open until the owner closes it.
Each conversation row gains a move menu listing the folders, an unfile
action, and a new-folder action. Creating, renaming, and accepting under a new
name use a sheet with a text field and an inline error, so a duplicate name or
a refused value is shown where it was typed and the proposal stays open;
deleting a folder uses the same confirmation idiom as deleting a conversation,
with a message saying that the conversations return to history and nothing is
deleted.
Against a server without the flag — a 404 or 405 on the folder list — the client marks folders unavailable, renders the history without folders, hides every folder control, and shows no banner; the unavailability is contained in the reconciliation and never surfaces as an error, so an older server costs one extra request per poll and nothing else. Native behavior is verified by the Swift testing lanes under ADR-0049, the same way the persona editor's is.
Scheduled sessions are not chat conversations, so they never enter a folder and their rows carry no move menu. The sidebar groups them by schedule in a Scheduled section between the folders and the unfiled history, a scheduler presentation designed in scheduling.md (ADR-0113). "Flat history" above means no folder sections or controls; Recent chats remains available, and schedule groups derive from session metadata alone and do not depend on the folder flag.
Events, telemetry, and privacy¶
folder.created,folder.renamed,folder.deleted,session.folder.changed,folder.proposal.created,folder.proposal.accepted,folder.proposal.declined,folder.proposal.withdrawn, andfolder.proposal.passare process events with stable derivation keys. Payloads carry identifiers, kinds, reasons, counts, usage, and cost — never a folder name, title, snippet, or rationale.- Logs on the pass and the routes carry error classes and opaque identifiers only, the rule email-experience.md applies to failures that may embed private text.
- Folder names and proposal text never render in a conversation prompt. The frozen prefix, the runtime-metadata row, and recall are byte-identical with and without folders; the prefix hash is the proof.
- Session metadata never enters the grouping call. The encoded input is built from titles, snippets, and folder names, and a test asserts that no reserved or client-supplied metadata key appears in the bytes sent.
Migration, configuration, and operations¶
Migration. One structural revision adds the three tables, the unique
constraint, the partial unique index, and the state index, with a
downgrade() that drops them in reverse — written because the round-trip
gate requires it, not as an operational promise
(event-log-and-persistence.md:1002-1004). It descends linearly from the current
head (event-log-and-persistence.md:925-928) and the expected-revision pin
moves in the same change. A pre-migration session reads as unfiled.
Configuration. The tuning values are a checked-in document, because none
of them differs between two deployments of the same revision
(bootstrap-and-composition.md:344-346). folders/profiles.yaml ships:
schema_version: 1
proposals:
enabled: true
threshold: 4 # members required for a new-folder proposal
max_open: 3 # open proposals per principal
interval_seconds: 900 # the pass's own timer in the maintenance worker
model_policy: balanced # a non-routed policy makes the grouper lexical-only
similarity_threshold: 0.2 # Jaccard floor for a lexical link
max_members: 12 # members per proposal
judgment_matching_enabled: false # the judgment matcher's own switch
judgment_match_threshold: 0.8 # probability floor for an accepted match
Its nine knobs join the executable inventory and the knob table in
bootstrap-and-composition.md; the first seven shipped with the milestone and
the two judgment knobs ship with the matcher. judgment_match_threshold is
above 0.5 and at most 1, so at most one option of a question can clear it.
One environment key, AGENT_THREAD_FOLDERS_API_ENABLED, gates the router and
the pass together, defaults off, and appears in .env.example in the same
change (bootstrap-and-composition.md:532-537). The matcher additionally needs
the JUDGMENT_PROVIDER selector typed-judgment.md
defines; with the knob on and no provider composed, the pass uses the inner
grouper alone and says so once in the log.
Disabling. Unsetting the flag hides the routes and stops the pass; the
tables, folders, memberships, and proposals remain, SessionView.folder_id
keeps reporting them, and the native client falls back to the flat list.
Setting proposals.enabled: false keeps the routes and stops only the pass.
Rolling the schema back is a restore, as it is for every revision.
Cost. The pass makes at most one model call per interval per principal,
capped at USD 0.10, and none when the set is full or fewer than threshold
unfiled sessions exist and no folder does. The judgment matcher adds at most
sixty-four judgment requests per pass under its own USD 0.01 ceiling, and none
when no folder exists. The audit event is the ledger.
Safety¶
- Names are refused at write time, everywhere. Create, rename, and acceptance run the secret-material and injection-pattern scans before persistence; a credential-shaped or instruction-shaped value is a refusal, not a warning. The grouper drops such a name before it becomes a proposal.
- Nothing automatic files a conversation. A proposal is a row the owner resolves; the only paths that write a membership are the owner's move and the owner's acceptance, and each is recorded as an event.
- The grouping call is data in, document out. User-trust labels on the input, a closed response schema, no tools, a fixed budget, and a local grounding check that discards anything the input does not support.
- A judgment can only propose. The matcher's answer is one of the options the pass offered or no match; it becomes an add-to-folder candidate the pass re-checks and the owner resolves. A judgment provider can be steered by text in what it judges, and here a steered answer costs one bad suggestion.
- Ownership is a repository predicate. Every read and write on all three tables carries tenant and principal in both stores under one contract, and a cross-principal read is an indistinguishable 404.
Hard gates¶
- The schema is additive and both stores agree. The migration creates
the folder, membership, and proposal tables with their constraints and
indexes, downgrades cleanly, reads a pre-migration session as unfiled,
and the folder store passes one contract suite on both adapters with
tenant and principal as predicates. Registered as
gate.folder.schema_additive, case. M29. - Folder invariants hold. Names are unique per principal on the
case-folded key with
conflict, refused when empty, over 64 characters, or failing a hazard scan withmalformed_request; rename preserves membership; delete unfiles every member and withdraws its open proposals in one transaction; and every write records an event. Registered asgate.folder.crud_invariants, case. M29. - Move semantics are exact. Filing and unfiling are idempotent; a
foreign or unknown folder or session is an indistinguishable 404; a
draft-only email, operational, scheduled, or delegated session is
conflictwith reasonsession_not_chat; and ADR-0050 session deletion leaves no membership behind. Owner-opened email discussions support manual filing without losing their thread or draft association (ADR-0184). Registered asgate.folder.move_semantics, case. M29. - Proposal eligibility is closed. Only the maintenance pass proposes,
only over unfiled chat sessions with titles, only at or above the
configured threshold for a new folder, and never over a session already
named by an open proposal; scheduled, delegated, and email sessions and
sub-threshold groups never yield a proposal. Registered as
gate.folder.proposal_eligibility, case. M29. - The open set is bounded and disjoint. At most
max_openproposals are open per principal, no session is named by two of them, further candidates wait, and resolution or withdrawal frees the slot. Registered asgate.folder.bounded_open_set, case. M29. - Decline is durable. A declined content key, and any candidate whose
member set is a near duplicate of a declined proposal's for the same kind
and target, is never proposed again — across re-derivation, a provider
change, or a fallback run. Registered as
gate.folder.decline_durable, case. M29. - Withdrawal follows the cause. Deleting, moving, or filing a member,
deleting the target folder, or an owner taking the proposed name
withdraws the open proposal in the causing transaction with its reason,
and the pass withdraws any it finds stale. Registered as
gate.folder.withdrawal, case. M29. - Grouping is grounded and falls back. Model output is validated
against the closed schema and grounded to the pass's inputs; an unknown
member, a duplicate, a sub-threshold new folder, and an over-length or
hazardous name are dropped; and a resolution, transport, budget, schema,
or parse failure yields the deterministic lexical result, which is
identical for identical input. Registered as
gate.folder.grounded_grouping, property. M29. - Acceptance is atomic and idempotent. Accept creates or targets the
folder, files the still-eligible members, withdraws competing proposals,
and resolves the proposal last in one unit of work; racing accept and
decline yield exactly one terminal state and the loser sees
conflict; accepting an accepted proposal replays. Registered asgate.folder.accept_atomic, case. M29. - Routes carry exact scopes and mount under the flag. Every folder
route requires exactly
session.readorsession.writeand mounts only underAGENT_THREAD_FOLDERS_API_ENABLED; with the flag unset the route inventory is unchanged and the session routes differ only by the additivefolder_id. Registered asgate.folder.routes_exact_scope, structural. M29. - Organization text stays out of prompts and logs. No session metadata
key enters the grouping call, folder names and proposal text never
render in a conversation prompt — the frozen prefix hash is identical
with and without folders — and every event, log line, and audit carries
identifiers and counts only. Registered as
gate.folder.content_free, case. M29. - The native sidebar degrades and reconciles. Against a server without
the flag the sidebar renders the flat index with no error and no folder
control; against one with it, folder sections, the cached
folder_id, and the proposal rows reconcile with the authoritative index, and the server's value wins. Registered asgate.folder.native_degradation, case. M29. - Judgment matching only proposes, is cleaned before egress, and falls
back whole. The matcher sends only cleaned titles, snippets, folder
names, and member titles: a conversation whose title fails a hazard scan is
not judged, and a hazardous snippet, member title, or folder is omitted. A
match is accepted only when the chosen option is not the explicit no-match
option and its probability meets the configured threshold; its output is
only ever an add-to-folder candidate the pass re-checks; any provider
error, deadline, or budget breach discards every judgment and yields the
inner grouper's candidates unchanged; and the pass audit carries the eight
judgment_*fields and never a title, snippet, or name. Registered asgate.folder.judgment_matching, property. M29.
Tracked metrics¶
- Proposal flow — created, accepted, declined, withdrawn counts by kind; a decline-heavy mix means the threshold or the similarity floor is too low.
- Fallback share — passes that used the lexical result alone; a rising share means the provider path is failing or over budget.
- Pass cost — tokens and cost per pass against the ceiling.
- Judgment share — add-to-folder proposals the matcher produced, and the share of them the owner accepts, against the lexical and model-assisted shares. It is the number that says whether the matcher earns its place.
- Unfiled share — unfiled chat sessions as a share of the index; the number the feature exists to lower.
Build sequence¶
- Domain types, the folder store port, both adapters under one contract suite, the migration. Gate 1 turns green here.
- The application service, the routes, the flag,
SessionView.folder_id, and write-time refusals. Gates 2, 3, and 10. - The proposal lifecycle: propose, accept, decline, withdrawal from every cause. Gates 5, 6, 7, and 9.
- The lexical grouper, the model-assisted grouper, the pass, its profile, and the maintenance sweep. Gates 4, 8, and 11.
- The native sidebar, on the existing Swift lanes. Gate 12.
- The judgment matcher, after the typed-judgment port of typed-judgment.md exists: the matcher, its two knobs, the audit fields, and the composed wrap. Gate 13.
Decisions¶
- A membership table, not a column on
sessions. The session model, its repository, its fixtures, and its deletion transaction stay untouched; the cascade does the deletion work; and with the flag off nothing about a session changes but one nullable view field. - Session scopes, not a new pair. No new authority is conferred, and a new scope would break every existing token grant for no security gain.
- The grouping is the key. A proposal has no stable source object the way a nomination has a belief, so the durable decline hangs on the kind, target, and member set, with a near-duplicate rule so a single new arrival cannot launder a declined group.
- Whole-proposal withdrawal. When a member moves, the proposal withdraws rather than shrinks; the next pass re-derives the remainder under a new key. Mutating an open proposal's members would change its content key under the owner's feet.
- Three outstanding proposals. A folder proposal is a heavier read than a one-line belief; three keep review a glance.
- Lexical fallback over no fallback. A pass that produces nothing when the provider is down is a feature that silently stops; token overlap over title and first message is weak but honest, and it is what the model refines rather than replaces.
- The matcher wraps; it does not replace. Token overlap cannot see that a
conversation about a carburetor belongs with motorcycle restoration, and a
closed Choice can. But a judgment provider names nothing, so new folders
stay with the groupers that can name them, and wrapping keeps the lexical
floor exactly where gate 8 put it. The existing
modelderivation labels the matcher's candidates; a new wire literal would buy the client nothing.
Open questions¶
- Whether accepted proposals should teach the lexical grouper — a folder's term profile is already the union of its members, so acceptance improves add-to-folder proposals without a learned policy; whether that is enough is a metric question.
- Whether a folder should collapse by default once it exceeds some size. The sidebar remembers the owner's expansion per device; in solo mode, the owner's default since 2026-09-19, every folder starts collapsed and one is open at a time, and with solo off every folder starts expanded. A size-based default can follow the unfiled-share and folder-size metrics.
Implementation checkpoint: 2026-09-16¶
The build sequence landed in five commits on the day of authorization. The
domain types, the FolderStore port, both adapters under
tests/contract/test_folder_store_contract.py, and migration a4f7c1e9d2b3
bind gate 1 through the PostgreSQL parity test. The folder service and the nine
routes bind gates 2, 3, 7, 9, and 10 through
tests/gates/test_folder_api_boundary_m29.py. The lexical grouper, the
model-assisted grouper, and the pass bind gates 4, 5, 6, 8, and 11 through the
unit suites under tests/unit/, with tests/gates/test_folder_m29.py
exercising the composed pass through the maintenance worker. The native
sidebar binds gate 12 through tests/native/test_folders_m29.py, which runs
the Swift package cases and the macOS journeys on a full Xcode installation and
never passes on a skipped lane. The folders/profiles.yaml document ships the
seven knobs of the configuration section and joins the executable inventory.
Registration is not release evidence: exact-head hosted CI, review, and
production delivery remain the open items in project state.
Implementation checkpoint: 2026-09-20¶
Build step 6 landed under ADR-0110. agent_core.folders.matching holds the
judgment matcher; the pass audit carries the eight judgment_* fields on
every pass; folders/profiles.yaml ships the two judgment knobs, off and at
0.8, which join the executable inventory; and the composition root wraps the
grouper only when the knob is on and a judgment provider is composed. Gate 13
binds through tests/gates/test_folder_m29.py::test_judgment_matching, which
drives the composed pass through the maintenance worker with a scripted judge,
accepts the resulting proposal through the routes, forces a provider failure
and finds the lexical proposal unchanged, and re-runs the matcher's unit cases
for cleaning, thresholds, deadlines, budgets, and pruning. The matcher is off
in every deployment until the owner sets the selector, the key, and the knob.