Skip to content

feat(conversations): merge labels onto a conversation, audited by key (#1637) - #1842

Merged
jhgaylor merged 1 commit into
mainfrom
stack/1637-labels-writer
Sep 11, 2026
Merged

jhgaylor merged 1 commit into
mainfrom
stack/1637-labels-writer

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

The writer the routes will use. Merge, not replace: a key the caller does not
name is left alone and a key whose value is nil is removed, so a run can
stamp one outcome without reading the rest of them first, and a channel
resume keeps the labels the binding already carried.

set_conversation_labels/4 is the door. It scopes by user_id and applies
one rule the contexts cannot leave to a controller: a sandbox may label its
own conversation only
. A conversation's sprite-scoped callback token
authenticates as the account, so every tenant check passes for every
conversation that account owns — the gap ADR 0045 describes. Pass
sandbox_key_id: and the conversation must be the one that token was minted
for, or the write is :sprite_may_not_label_another_conversation. Three
request paths will write labels, and the rule has to hold on the door rather
than on whichever of them remembered.

_unsafe_merge_labels/3 is the unscoped writer beneath it, for callers that
have already established ownership.

Two details worth the words:

  • A count refusal names a key the caller sent. check/1 sees the merged
    map, so merging one label into a conversation that already holds 32 would
    otherwise blame whichever pre-existing key sorts into the boundary
    position and send somebody to fix a label they never touched.
    check_merge/2 words it from the write instead.
  • A merge that changes nothing writes nothing and records nothing. A
    deterministic run re-stamping the same outcome on every tick is the normal
    case, not an event.

Audited as conversation.labels_set with the keys written and the keys
removed, never the values (ADR 0013).

Second 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-labels-column branch from 2ea3c2f to 6a25382 Compare September 11, 2026 02:27
@jhgaylor
jhgaylor force-pushed the stack/1637-labels-writer branch from 72d8623 to 3394aab Compare September 11, 2026 02:27
@jhgaylor
jhgaylor changed the base branch from stack/1637-labels-column to main September 11, 2026 02:44
…#1637)

The writer the routes will use. Merge, not replace: a key the caller does not
name is left alone and a key whose value is `nil` is removed, so a run can
stamp one outcome without reading the rest of them first, and a channel
resume keeps the labels the binding already carried.

`set_conversation_labels/4` is the door. It scopes by `user_id` and applies
one rule the contexts cannot leave to a controller: **a sandbox may label its
own conversation only**. A conversation's `sprite`-scoped callback token
authenticates as the *account*, so every tenant check passes for every
conversation that account owns — the gap ADR 0045 describes. Pass
`sandbox_key_id:` and the conversation must be the one that token was minted
for, or the write is `:sprite_may_not_label_another_conversation`. Three
request paths will write labels, and the rule has to hold on the door rather
than on whichever of them remembered.

`_unsafe_merge_labels/3` is the unscoped writer beneath it, for callers that
have already established ownership.

Two details worth the words:

- **A count refusal names a key the caller sent.** `check/1` sees the merged
  map, so merging one label into a conversation that already holds 32 would
  otherwise blame whichever *pre-existing* key sorts into the boundary
  position and send somebody to fix a label they never touched.
  `check_merge/2` words it from the write instead.
- **A merge that changes nothing writes nothing and records nothing.** A
  deterministic run re-stamping the same outcome on every tick is the normal
  case, not an event.

Audited as `conversation.labels_set` with the keys written and the keys
removed, never the values (ADR 0013).

Second 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-labels-writer branch from 3394aab to 14f9ccd Compare September 11, 2026 02:44
@jhgaylor
jhgaylor merged commit 4bad52b into main Sep 11, 2026
23 checks passed
@jhgaylor
jhgaylor deleted the stack/1637-labels-writer 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