feat(conversations): labels with an AND list filter - #1676
Closed
lex00 wants to merge 4 commits into
Closed
Conversation
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>
lex00
force-pushed
the
issue-1637-conversation-labels
branch
from
September 6, 2026 20:25
817fdde to
3cfb1cf
Compare
lex00
force-pushed
the
issue-1636-apply-kinds
branch
from
September 6, 2026 20:44
0268a67 to
18c091f
Compare
This was referenced Sep 10, 2026
Collaborator
|
Re-cut as a stack of nine under the no-big-PRs rule, rebased onto current
The combined tip is identical to this branch over 🤖 Generated with Claude Code |
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.labelsto 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 ajsonb_path_opsGIN index. Limits, NUL bytes and non-string values are refused with a 422 that names the offending key.POST /api/conversations(including achannel_idresume, which merges) and onPOST /api/team/:agent_id/messages. AddPATCH /api/conversations/:id/labelswith merge semantics; anullvalue removes a key.session/updatewhosesessionUpdateis_fountain/labels. A sandbox callback token may label only the conversation it was minted for, on every door.label=key:valueparameter, AND-combined, onGET /api/conversationsandGET /api/team/:agent_id/conversations, declared as an array parameter and reshaped before the OpenAPI cast so a single value still casts.labelsunderdatain everyconversation.*webhook payload. Audit label changes by key only.conversations({labels})andsetLabels().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:
mix compile --warnings-as-errors,mix format --check-formatted,mix credo --strict, dialyzer, sobelow anddeps.unlock --unusedare clean. The prod release assembles.scripts/docs-style.pyis clean.