Skip to content

feat(conversations): labels with an AND list filter - #1676

Closed
lex00 wants to merge 4 commits into
issue-1636-apply-kindsfrom
issue-1637-conversation-labels
Closed

feat(conversations): labels with an AND list filter#1676
lex00 wants to merge 4 commits into
issue-1636-apply-kindsfrom
issue-1637-conversation-labels

Conversation

@lex00

@lex00 lex00 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

A program running as a teammate produces runs a person wants to slice by fact, not by text: every tick where drift was found, every apply that was gated, every run against env=prod. This PR adds free-form labels to a conversation so a run can stamp its own outcome and the list route can filter on it.

  • Add labels to conversations: a string map of at most 32 entries, keys at most 64 bytes and values at most 256 bytes, as a jsonb column with a jsonb_path_ops GIN index. Limits, NUL bytes and non-string values are refused with a 422 that names the offending key.
  • Set labels on POST /api/conversations (including a channel_id resume, which merges) and on POST /api/team/:agent_id/messages. Add PATCH /api/conversations/:id/labels with merge semantics; a null value removes a key.
  • Let the agent stamp its own conversation over ACP with a session/update whose sessionUpdate is _fountain/labels. A sandbox callback token may label only the conversation it was minted for, on every door.
  • Filter with a repeatable label=key:value parameter, AND-combined, on GET /api/conversations and GET /api/team/:agent_id/conversations, declared as an array parameter and reshaped before the OpenAPI cast so a single value still casts.
  • Render labels as chips on the dashboard and the admin user page, with the same filter in the dashboard URL. Carry labels under data in every conversation.* webhook payload. Audit label changes by key only.
  • Regenerate the SDK contract and TypeScript types; the TypeScript client gains conversations({labels}) and setLabels().

The conversations app and the team app live in their own repositories and are not changed here.

Closes #1637

Stacked on #1636; merge that first.

Validation:

  • Full suite on the four-branch stack: 4579 tests, 0 failures (core and ee), 144 (fountain_buzz), 33 (fountain_support).
  • mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict, dialyzer, sobelow and deps.unlock --unused are clean. The prod release assembles.
  • The SDK contract check, the TypeScript verifier, typecheck and tests, the Python verifier and the conformance lint pass. scripts/docs-style.py is clean.
  • Not run here: vale, okf and destink (not installed on the build machine).

@lex00 lex00 added enhancement New feature or request area:api HTTP API, OpenAPI spec, CLI contract area:conversations Conversation lifecycle, turns, streaming, reattach, and orchestration labels Sep 6, 2026
A conversation carries free-form `labels`, a jsonb map of at most 32
key/value strings, so a program running as a teammate can stamp what its
run found — `env=prod`, `drift=true` — and slice the list by it. Those
facts are known when the turn ends and are not text worth searching, so
they sit beside the title rather than in the search index.

The rule lives in `Fountain.Conversations.Labels` and reaches the row
through the conversation changeset, so every door enforces the same
limits and a refusal always names the offending key under `errors.labels`.
Writes merge: a key not named is left alone, a key whose value is null is
removed.

Four doors set them. `POST /api/conversations` takes `labels` at launch,
and a `channel_id` resume merges them into the conversation it hands
back. `POST /api/team/:agent_id/messages` merges them into the
conversation the message lands on, before the turn is queued.
`PATCH /api/conversations/:id/labels` merges by hand; the account's own
key may label any of its conversations, while a sandbox callback token
may label only the conversation it was minted for and is otherwise
refused with 403 `sprite_may_not_label_another_conversation`. An agent
inside a turn stamps its own run with a `_fountain/labels` extension
update on `session/update`, which the turn machine reads and never puts
on the transcript.

`GET /api/conversations?label=env:prod&label=drift:true` and the same
parameter on `GET /api/team/:agent_id/conversations` filter with jsonb
containment against a GIN index; the parameter is repeatable, combined
with AND, and each value splits on its first colon only. The console's
dashboard renders labels as chips and takes the same filter from the URL,
and `conversation.*` webhook payloads carry `labels` under `data`.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
Review of the labels branch found the sandbox restriction guarding one door
of three, a byte that could kill a running turn, and a refusal that blamed
the wrong key.

**One door for every label write.** The sprite rule was on
`PATCH /api/conversations/:id/labels` only. `POST /api/team/:agent_id/messages`
and a `channel_id` resume on `POST /api/conversations` also write labels onto
an existing conversation, and both accept a sandbox token, so a sprite minted
for conversation A could relabel conversation B of the same tenant. Both now
write through `Conversations.set_conversation_labels/4`, which is where the
rule lives; the writer beneath it is `_unsafe_merge_labels/3` so a call site
that skips the rule reads as skipping it. `FountainWeb.SandboxKey` derives the
key id once for all three controllers, and the refusal is a 403 in
`FallbackController` rather than per-controller.

**Nothing in a label can end a run.** A NUL byte is legal in JSON and illegal
inside Postgres `jsonb`, so an unchecked one raised `Postgrex.Error` out of
`Repo.update` — a 500 on the HTTP doors, and on the ACP path a raise that
travelled through the turn machine and took the ConversationServer and its
in-flight turn with it. `check_entry/2` refuses it with the key named, and
`Labels._unsafe_stamp/2` rescues so that a shape we have not thought of costs
the stamp rather than the turn.

**The count names a key the caller sent.** The ceiling was checked against the
merged map, so adding one label to a conversation holding 32 blamed whichever
*existing* key sorted into the boundary. `Labels.check_merge/2` runs with the
incoming map in scope and names the first key of this write that does not fit.

**The filter is an array on the wire.** `label` was declared as a single
string, so generated clients got a one-pair filter. It is now
`type: array, style: form, explode: true`. OpenApiSpex reads query parameters
straight out of Plug, which keeps only the last of a repeated key and would
have refused a single `?label=env:prod` as "not an array", so
`FountainWeb.Plugs.RepeatedQueryParam` reshapes the key before the cast.
Generated clients now see `label?: string[]`.

Also: `Labels.stamp/2` becomes `_unsafe_stamp/2` (unscoped write by id);
`describe/1` cuts by bytes and backs off to a whole codepoint so an error
message stays encodable; a non-map `labels` is a 422 on every door instead of
being silently ignored on two of them; the dead `parse_filter/1` binary clause
is gone; the dashboard uses `LabelFilter.from_uri/1` rather than its own copy
of the parse; `label[]=` is documented; and the webhook reference says an
agent can stamp labels itself.

Tests: the sprite refusal on all three doors, NUL on the wire and through
ACP, the create path unstubbed, the channel-resume merge, labels on the
team's fresh-conversation branch, agent output that merely mentions
`_fountain/labels`, and that an unrelated conversation update preserves
labels.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
conn.query_string is always a binary, so the catch-all collect/2 clause
could never match and dialyzer flagged it.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…meter (#1637)

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
@jhgaylor

Copy link
Copy Markdown
Collaborator

Re-cut as a stack of nine under the no-big-PRs rule, rebased onto current main (this branch's base, #1636, has since merged as #1799-#1806):

The combined tip is identical to this branch over apps/, apart from three prose fixes the destink and STE gates asked for (this branch's validation notes say destink and vale were not run). Leaving this open for now because #1677 and #1678 still point at it.

🤖 Generated with Claude Code

https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf

@lex00

lex00 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #1841 to #1849; nothing points at this branch any more.

@lex00 lex00 closed this Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api HTTP API, OpenAPI spec, CLI contract area:conversations Conversation lifecycle, turns, streaming, reattach, and orchestration enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants