Skip to content

docs: labels in the manual and the SDK guide, and one SDK release (#1637) - #1849

Merged
jhgaylor merged 6 commits into
mainfrom
stack/1637-release
Sep 11, 2026
Merged

jhgaylor merged 6 commits into
mainfrom
stack/1637-release

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #1637.

The concept page and the SDK guide get their sections, and the server
CHANGELOG gets the entry the whole stack earns. The API reference already
grew alongside each PR that built a piece of this.

One release for the eight PRs beneath: @agentshit/fountain-sdk 1.25.0, with
conversations({labels}), Conversation#setLabels(), the generated types,
and the query builder that expands an array into a repeated key. The earlier
PRs regenerated types without bumping the version, because merging a bump
publishes.

Ninth 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 and others added 6 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
)

The dashboard's recent-conversations list and the admin user page render a
conversation's labels as small `key=value` chips, sorted by key so a row reads
the same on every render, and nothing at all when a conversation has none — an
empty row of chips is noise on a list where most conversations carry none.

The dashboard takes the same `?label=env:prod` filter the API does, the way
/audit takes its filters: repeatable, AND-combined, and a link you can send
someone. Clicking a chip adds that label to whatever is already filtered, so
two clicks narrow rather than replace.

Two details the console forces:

- **Read from `uri`, not `params`.** A LiveView gets no plug pipeline and its
  params are collapsed the same way Plug collapses them, so the repeated key
  has to come back off the URI. `LabelFilter.from_uri/1` runs the same parser
  the API route uses, so the console and the API cannot disagree about what
  `?label=env:prod` means.
- **The path is built by hand**, not through `~p`'s query encoding, because
  the filter is a repeated `label=` key and the sigil builds a map.

A filter value the vocabulary does not recognise filters nothing and leaves
the chips showing what was asked for, the way the audit page treats a
half-typed date. A filter that matches nothing says so rather than silently
showing everything.

Eighth of nine on #1637.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
)

Closes #1637.

The concept page and the SDK guide get their sections, and the server
CHANGELOG gets the entry the whole stack earns. The API reference already
grew alongside each PR that built a piece of this.

One release for the eight PRs beneath: `@managoat/fountain-sdk` 1.27.0, with
`conversations({labels})`, `Conversation#setLabels()`, the generated types,
and the query builder that expands an array into a repeated key. The earlier
PRs regenerated types without bumping the version, because merging a bump
publishes.

Ninth 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-console-chips branch from eed5631 to faf3ae6 Compare September 11, 2026 03:10
@jhgaylor
jhgaylor changed the base branch from stack/1637-console-chips to main September 11, 2026 03:10
@jhgaylor
jhgaylor merged commit aee0e2a into main Sep 11, 2026
24 checks passed
@jhgaylor
jhgaylor deleted the stack/1637-release branch September 11, 2026 03:24
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 12, 2026
The manual and the three SDKs the API PR left, plus the one version bump the
stack makes.

`docs/api.md` gets the endpoint, the three-state body, what moves with it (the
machine's binding identity, the skills reconciliation, the revision) and every
refusal by status. `docs/concepts/conversation.md` gets the short version
beside prompts and interrupts, and `docs/reference/webhooks.md` gets the
`configuration`/`done` stage.

Python, Elixir and Swift get the method, each in its own idiom for the one
thing the wire format needs: an omitted field and an explicit null are
different requests. Python uses an `_UNSET` sentinel, Elixir reads
`Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate`
rather than a plain optional that cannot tell "leave it" from "remove it".
Each SDK's test asserts the encoded body, not just the call.

Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to
0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed
1.27.0 on main while this stack was open.

**Re-check the TypeScript version against `main` immediately before merge.**
Concurrent bumps must land in ascending order, and this one has already been
re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish.

Checked: `docs-style.py` (two pre-existing findings, neither on a page this
touches), `vale lint` (no new hit on any of the six gating rules), `destink`
(112 pages clean), `docs_test.exs`, and all four SDK suites and contract
checks, plus `swift test` (82 tests).

Part 8 of 8 for #1565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
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.

Labels on a conversation, with a list filter, so a run can stamp its own outcome

3 participants