feat(conversations): merge labels onto a conversation, audited by key (#1637) - #1842
Merged
Merged
Conversation
lex00
approved these changes
Sep 10, 2026
jhgaylor
force-pushed
the
stack/1637-labels-column
branch
from
September 11, 2026 02:27
2ea3c2f to
6a25382
Compare
jhgaylor
force-pushed
the
stack/1637-labels-writer
branch
from
September 11, 2026 02:27
72d8623 to
3394aab
Compare
…#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
force-pushed
the
stack/1637-labels-writer
branch
from
September 11, 2026 02:44
3394aab to
14f9ccd
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.
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
nilis removed, so a run canstamp one outcome without reading the rest of them first, and a channel
resume keeps the labels the binding already carried.
set_conversation_labels/4is the door. It scopes byuser_idand appliesone rule the contexts cannot leave to a controller: a sandbox may label its
own conversation only. A conversation's
sprite-scoped callback tokenauthenticates 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 mintedfor, or the write is
:sprite_may_not_label_another_conversation. Threerequest paths will write labels, and the rule has to hold on the door rather
than on whichever of them remembered.
_unsafe_merge_labels/3is the unscoped writer beneath it, for callers thathave already established ownership.
Two details worth the words:
check/1sees the mergedmap, 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/2words it from the write instead.deterministic run re-stamping the same outcome on every tick is the normal
case, not an event.
Audited as
conversation.labels_setwith the keys written and the keysremoved, 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-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