feat(webhooks): conversation events carry the conversation's labels (#1637) - #1847
Merged
Merged
Conversation
jhgaylor
force-pushed
the
stack/1637-acp-stamp
branch
from
September 10, 2026 13:45
14ebb25 to
7cbd745
Compare
jhgaylor
force-pushed
the
stack/1637-webhook-labels
branch
from
September 10, 2026 13:45
1a8fe7e to
2574a51
Compare
lex00
approved these changes
Sep 10, 2026
jhgaylor
force-pushed
the
stack/1637-acp-stamp
branch
from
September 11, 2026 02:27
7cbd745 to
a9afd3c
Compare
jhgaylor
force-pushed
the
stack/1637-webhook-labels
branch
from
September 11, 2026 02:27
2574a51 to
6931552
Compare
jhgaylor
force-pushed
the
stack/1637-acp-stamp
branch
from
September 11, 2026 02:44
a9afd3c to
d6aa595
Compare
jhgaylor
force-pushed
the
stack/1637-webhook-labels
branch
from
September 11, 2026 02:44
6931552 to
3134d07
Compare
jhgaylor
force-pushed
the
stack/1637-acp-stamp
branch
from
September 11, 2026 02:53
d6aa595 to
356a6b9
Compare
jhgaylor
force-pushed
the
stack/1637-webhook-labels
branch
from
September 11, 2026 02:53
3134d07 to
eaef194
Compare
…mbined (#1637) `GET /api/conversations?label=env:prod&label=drift:true` keeps the conversations that carry both. The query is jsonb containment, which the GIN index the column already has serves; a row with more labels than the filter names still matches. Each value splits on its **first** colon only, so `label=path:a:b` filters `path` for `a:b`, and a value with no colon or an empty key is a 400 `invalid_label_filter` rather than a silent match-all. The parameter is declared honestly as an array (`style: form, explode: true`), which needed one piece of plumbing to be possible at all — `FountainWeb.Plugs.RepeatedQueryParam`. Two things sit in the way of a repeated query key here: - `Plug.Conn.Query` collapses a repeated key to its **last** value, so `conn.params["label"]` would be `"drift:true"` and the first filter would be gone; - `OpenApiSpex.CastParameters` reads query parameters straight out of Plug and implements only the `explode: false` comma-joined form itself, so a parameter declared as an array would be handed that string and refuse it as "not an array" — turning an ordinary `?label=env:prod` into a 422. So the plug reads the raw query string, collects every occurrence, and writes the list back onto both `query_params` and `params` before the cast runs. `label[]=` is collected too, for a client whose HTTP layer only builds arrays that way. `FountainWeb.LabelFilter` is the parsing, in one place, because the team route takes the same parameter next and a second copy is exactly how the two would drift. The TypeScript SDK gains `conversations({labels})`, and its query builder now expands an array into a repeated key instead of joining it with commas — joining would have sent one filter the server cannot parse. Fourth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
…1637) `POST /api/team/:agent_id/messages` takes `labels`, merged onto the conversation the message lands on — the teammate's current one, or the fresh one a thread past resuming is replaced by, in which case they ride in the create attrs so the conversation is not labelled twice. **Merged before the prompt is queued.** A label the limits refuse therefore means nothing happened at all, rather than "the message went and the labels did not". Through `Conversations.set_conversation_labels/4` and not the writer beneath it, because this route accepts a sandbox's own `sprite` token and the teammate's conversation is somebody else's conversation as far as that token is concerned. The refusal is the same 403 `PATCH .../labels` gives. `GET /api/team/:agent_id/conversations` takes the same repeatable, AND-combined `label` parameter, over the same `FountainWeb.LabelFilter` and the same containment fragment, so the two lists cannot disagree about what `?label=env:prod` means. Fifth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
A deterministic run knows what it found by the time the turn ends. Making it call `PATCH /api/conversations/:id/labels` to say so means a second credential, a second round trip and a conversation id it has to have been told. It is already holding an ACP session with Fountain on the other end. So `session/update` with `sessionUpdate: "_fountain/labels"` merges labels onto the conversation the session belongs to. ACP keeps a leading `_` for extensions, and `Managoat.ACP.Peer` forwards `session/update` and drops every other notification method, which is why this rides there rather than on a method of its own. It is a control message and not something the agent said: it opens no turn, re-arms no quiet timer and never reaches the transcript — the third exception in `TurnMachine.handle/3` beside a replayed line and session metadata. A cheap substring test runs before the decode, because this sees every protocol line of every turn and all but a handful of them are agent output. **Nothing a stamp contains can take the turn down.** `Labels._unsafe_stamp/2` logs and drops a stamp the limits refuse, and rescues anything that raises on its way to the database. The run is mid-turn doing real work; losing it because a value was 300 bytes long, or held a byte `jsonb` will not store, would be the worse outcome by a distance. That is also why the NUL check exists at all — Postgres refuses a NUL inside `jsonb`, and without the check the write would come back as a raised `Postgrex.Error` travelling up through the turn machine. Unscoped, and legitimately so: the id is the one this machine's own `ConversationServer` was started with, so it cannot name another conversation, of this tenant or any other. Recorded as `sprite` (ADR 0013). Sixth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
…1637) A receiver that routes on `env=prod` or files a `drift=true` run should not have to call back for the labels it was already told about. `data.labels` rides on every `conversation.*` payload, read at dispatch time, and is an object — empty rather than null — when the conversation carries none. This stays inside the payload's standing rule, which is ids, a stage, a status and a duration and nothing else. Labels are ids and facts a caller put there itself, never content. The manual says so plainly, because the agent in the sandbox can stamp them too: a label is data your own run wrote, not a value Fountain derived. Seventh of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
jhgaylor
force-pushed
the
stack/1637-acp-stamp
branch
from
September 11, 2026 03:10
356a6b9 to
590e687
Compare
jhgaylor
force-pushed
the
stack/1637-webhook-labels
branch
from
September 11, 2026 03:10
eaef194 to
3b677a6
Compare
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 receiver that routes on
env=prodor files adrift=truerun should nothave to call back for the labels it was already told about.
data.labelsrides on every
conversation.*payload, read at dispatch time, and is anobject — empty rather than null — when the conversation carries none.
This stays inside the payload's standing rule, which is ids, a stage, a
status and a duration and nothing else. Labels are ids and facts a caller put
there itself, never content. The manual says so plainly, because the agent in
the sandbox can stamp them too: a label is data your own run wrote, not a
value Fountain derived.
Seventh of nine on #1637.
The stack for #1637
Nine PRs, each based on the one above it. Merge top down, and do not
--delete-branchwhile a child still points at a branch.stack/1637-labels-columnConversations.Labels's limits,labelson createstack/1637-labels-writerset_conversation_labels/4,_unsafe_merge_labels/3, the sandbox rule, the audit event, the channel resumestack/1637-labels-apiPATCH /api/conversations/:id/labels,FountainWeb.SandboxKey, the 403,labelson the wirestack/1637-label-filter?label=key:value,RepeatedQueryParam,LabelFilter, the SDK filterstack/1637-team-labelsstack/1637-acp-stamp_fountain/labelsover the agent's own ACP sessionstack/1637-webhook-labelsdata.labelson everyconversation.*payloadstack/1637-console-chipsstack/1637-releaseThis is #1676 re-cut under the no-big-PRs rule, rebased onto current
main(re-rebased after #1832-#1839 landed).The combined tip is byte-identical to #1676 over
apps/, apart from threeprose fixes the destink and STE gates asked for and the regenerated contract
and SDK types.
Validation on the tip of the stack:
mix precommitclean, core and ee4,911 tests, 0 failures,
fountain_buzz144,fountain_support33. TheSDK contract
--check, the TypeScript typecheck and its 105 tests pass.docs-style.py,vale(0 errors) anddestinkare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf