Skip to content

feat(webhooks): conversation events carry the conversation's labels (#1637) - #1847

Merged
jhgaylor merged 4 commits into
mainfrom
stack/1637-webhook-labels
Sep 11, 2026
Merged

jhgaylor merged 4 commits into
mainfrom
stack/1637-webhook-labels

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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.


The stack for #1637

Nine PRs, each based on the one above it. Merge top down, and do not
--delete-branch while a child still points at a branch.

# branch owns
1 stack/1637-labels-column the jsonb column, the GIN index, Conversations.Labels's limits, labels on create
2 stack/1637-labels-writer set_conversation_labels/4, _unsafe_merge_labels/3, the sandbox rule, the audit event, the channel resume
3 stack/1637-labels-api PATCH /api/conversations/:id/labels, FountainWeb.SandboxKey, the 403, labels on the wire
4 stack/1637-label-filter ?label=key:value, RepeatedQueryParam, LabelFilter, the SDK filter
5 stack/1637-team-labels labels on a team message, and the same filter on a teammate's list
6 stack/1637-acp-stamp _fountain/labels over the agent's own ACP session
7 stack/1637-webhook-labels data.labels on every conversation.* payload
8 stack/1637-console-chips chips on the console lists, and the dashboard URL filter
9 stack/1637-release the manual, the CHANGELOG, one SDK release (1.27.0)

This 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 three
prose fixes the destink and STE gates asked for and the regenerated contract
and SDK types.

Validation on the tip of the stack: mix precommit clean, core and ee
4,911 tests, 0 failures, fountain_buzz 144, fountain_support 33. The
SDK contract --check, the TypeScript typecheck and its 105 tests pass.
docs-style.py, vale (0 errors) and destink are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf

@jhgaylor
jhgaylor force-pushed the stack/1637-acp-stamp branch from 7cbd745 to a9afd3c Compare September 11, 2026 02:27
@jhgaylor
jhgaylor force-pushed the stack/1637-webhook-labels branch from 2574a51 to 6931552 Compare September 11, 2026 02:27
@jhgaylor
jhgaylor force-pushed the stack/1637-acp-stamp branch from a9afd3c to d6aa595 Compare September 11, 2026 02:44
@jhgaylor
jhgaylor force-pushed the stack/1637-webhook-labels branch from 6931552 to 3134d07 Compare September 11, 2026 02:44
@jhgaylor
jhgaylor force-pushed the stack/1637-acp-stamp branch from d6aa595 to 356a6b9 Compare September 11, 2026 02:53
@jhgaylor
jhgaylor force-pushed the stack/1637-webhook-labels branch from 3134d07 to eaef194 Compare September 11, 2026 02:53
jhgaylor and others added 4 commits September 10, 2026 23:10
…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
jhgaylor force-pushed the stack/1637-acp-stamp branch from 356a6b9 to 590e687 Compare September 11, 2026 03:10
@jhgaylor
jhgaylor force-pushed the stack/1637-webhook-labels branch from eaef194 to 3b677a6 Compare September 11, 2026 03:10
@jhgaylor
jhgaylor changed the base branch from stack/1637-acp-stamp to main September 11, 2026 03:10
@jhgaylor
jhgaylor merged commit ffd4f1b into main Sep 11, 2026
23 checks passed
@jhgaylor
jhgaylor deleted the stack/1637-webhook-labels branch September 11, 2026 03:24
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.

3 participants