M1-59: Reactions and pins¶
Status: ready | Depends on: M1-58 | Milestone: M1
Goal¶
After this ticket people and agents react to messages with any single Unicode symbol, and holders
of manage pin messages as moderation. Reactions are free expression and never a status: they do
not touch trigger_state. Both are idempotent, produce the durable events reaction.added,
reaction.removed, pin.added, pin.removed, and appear in every Message representation as a
summary, the caller's own reactions and the pinned flag. Clients render reaction bars, the "who
reacted" list and the pinned list of a thread from this.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 7 "M1 Foundation" (row M1-59, "Red team").docs/briefs/README.md.docs/spec/00-glossary.md: reaction, pin, trigger state.docs/spec/01-product.md: "Feature list" 3.3 Messages (reactions, pins).docs/spec/02-architecture.md: 4.1, 4.3 rowmessaging(portsReactions,Pins), 4.4, 4.7, "Layers within a module" (no third-party libraries indomainandapp), "Transactions and Unit of Work", "Request path".docs/spec/03-data-model.md: "Principles" (items 10, 12, 13), tablesmessages,reactions,pins; "Transaction rules" 6.3.docs/spec/04-sync-protocol.md: "Envelope", "Delivery guarantee" (item 3), "Ordering".docs/spec/05-actions.md: rules 1.1, 1.2, 1.8; "Target of a resource" (resourcesreaction,pin); "Decision"; table 4.7 (reaction.add,reaction.remove,pin.create,pin.delete,message.read), table 4.6 (thread.read); section 6 rowsreaction.*,pin.*; R-AZ-08.docs/spec/06-api.md: "Basic rules", "Authorization in the API", "Errors", "Pagination", "Idempotency" (PUT and DELETE), "Endpoints" > "Messages", "Configuration values of this chapter".docs/spec/07-permissions.md: 4.1, 4.2, 4.3, 7.4.docs/spec/15-client-architecture.md: section 5 (event application byresource.version) and 6.1 (reactions_summary).docs/spec/19-security.md: "Input validation in the server" (SEC-IN-1, SEC-IN-2), SEC-LOG-4.docs/api/openapi.yaml: operationsaddReaction,removeReaction,listReactions,pinMessage,unpinMessage,listPins; schemasMessage(reactions,my_reactions,pinned),ReactionSummary,Reaction,ReactionPage,Pin,PinList,ReactionPayload,PinPayload,ReactionAddedEvent,ReactionRemovedEvent,PinAddedEvent,PinRemovedEvent; parametersEmojiPath,Cursor,Limit; responseGone.- ADRs:
docs/adr/0008-hexagonal-modules-and-checked-dependency-rules.md,0015-error-format-rfc-9457-idempotency-pagination.md,0017-one-authorization-function-404-instead-of-403-mandatory-registration.md. - Code of earlier tickets:
internal/messaging(M1-56: tables, representation function, resource descriptor),internal/identityportDirectory(PrincipalRef data; M1-26),kernel/authorize(Scope; M1-28), the cursor codec of earlier list operations (search fornext_cursorinservices/server/internal),pgtest(M1-12),api/operations.yaml(M1-17),architecture/modules.yaml(M1-05), the configuration schema (M1-07) andtools/configdoc(M1-08). docs/quality/gates.md,docs/quality/licenses.md,CONTRIBUTING.md,docs/quality/red-team-gate.md,services/server/internal/messaging/README.md.- Tables:
reactions,pins(new),messages(read and row lock). Actions:reaction.add,reaction.remove,pin.create,pin.delete,message.read,thread.read.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: write the failing test, record it red, then make it green.
- No en dash or em dash characters in anything you write; use a hyphen or rephrase.
- White label: examples use Alex, Sam, Robin, Support, Marketing, Engineering, Research and
example.comonly. - Limits only through named configuration keys from
docs/operations/configuration.md. - A reaction is exactly one Unicode grapheme cluster, checked in the application
(03
reactions); a reaction is never a status and never changestrigger_state(00 "Reaction"; 01 3.3; 03reactions). - A repeated reaction of the same principal with the same symbol is idempotent (PK of
reactions; 06 "Idempotency": PUT and DELETE). reaction.addneedswrite;reaction.removeremoves only the caller's own reaction (05 table 4.7). Read-only channels refuse both with403 channel_read_only(05 rule 1.8).- Pinning and unpinning need
manage(05pin.create,pin.delete; R-AZ-08); a principal who may read but lacksmanagegets403, one who may not read gets404(05 rule 2). - Events:
reaction.added,reaction.removedwith receive actionmessage.read;pin.added,pin.removedwith receive actionthread.read; resource kindmessage(05 section 6). my_reactionsappears only in REST responses, never in events (openapiMessage).- Lists filter in SQL with the visible channels and check each element with
authorize(07 7.4). - Third-party libraries only in adapters (02 "Layers within a module"), pinned and with an allowed
license (
quality/licenses/allowed.txt).
Scope¶
All paths below services/server/ unless stated.
- Migration
migrations/<next number>_reactions_pins.sql: tablesreactionsandpinsexactly as 03 (columns, primary keys, FKs with ON DELETE behaviour, index(thread_id, pinned_at DESC)onpins). Use the next free goose number at your local merge; renumber before merging if a parallel ticket took it. - Port
internal/messaging:ReactionswithAdd(ctx, messageID ids.ID, emoji string) error,Remove(ctx, messageID ids.ID, emoji string) error,List(ctx, q ReactionListQuery) (ReactionPage, error).PinswithPin(ctx, messageID ids.ID) error,Unpin(ctx, messageID ids.ID) error,List(ctx, threadID ids.ID) ([]PinView, error).ids.IDis the UUID type ofkernel/ids; the actor comes from the context.
internal/messaging/domain: summary ordering (note 7).internal/messaging/app: use cases for the six operations; outbound portSymbolNormalizer{Normalize(raw string, maxBytes int) (string, error)}; batch loaders that fillreactions,my_reactions(REST only) andpinnedin the single representation function of M1-56 for one message or a page (one query per field set, no query per message).internal/messaging/adapters/unicode: theSymbolNormalizerimplementation (note 3) withgithub.com/rivo/uniseg(MIT, UAX #29 grapheme clusters) andgolang.org/x/text/unicode/norm(BSD-3-Clause, NFC), both pinned to a released version ingo.mod; allow exactly these two libraries for this adapter inarchitecture/modules.yamland regenerate.go-arch-lint.yml.internal/messaging/adapters/postgres/queries/reactions.sql,pins.sql, regeneratedsqlcgen/.internal/messaging/adapters/httpapi:AddReaction,RemoveReaction,ListReactions,PinMessage,UnpinMessage,ListPins.- The specification is in place:
docs/api/openapi.yamlhas410 GoneonaddReactionandpinMessage; 03 9 definesreaction.emoji_max_bytes,reaction.max_distinct_per_messageandpin.max_per_thread; 03reactionsandpinsstate the limits (400 validation_failed, codelimit_reached); 04 9 item 3 and 15 5 state that reaction and pin events apply inseqorder without raising the message version. Change them only where the implementation proves them wrong. - Configuration: add the three keys to the configuration schema (M1-07) and keep
docs/operations/configuration.mdcurrent (make config-doc-check). api/operations.yaml: enable the six operations; regenerate.- Docs:
internal/messaging/README.md(English, existing structure),services/server/CHANGELOG.md,docs/DECISIONS.mdentry for note 3 (notes 6 and 9 are in the specification).
Out of scope¶
- Editing, deleting, revisions and redaction (M1-58), except the coordination of note 9.
- History paging (M1-57), mentions and templates (M1-60), push for reactions (none planned).
- Delivery of the events over the gateway (M1-65); client rendering (M4); import of pins (M8).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-59-messaging-reactions-pins, created from the base branch the launcher names, after checking that the tree is clean and M1-56 is merged. - Conventional Commits with scopes
messaging,api,config, every commit with DCO sign-off (git commit -s); squash into meaningful commits before the local merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope list, except the CHANGELOG entry and
docs/DECISIONS.mdentries named here. - Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0; PostgreSQL 18 throughpgteston a random port; without a Docker runtime the test fails, it never skips. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them. - Never read or print secrets; never open
.envfiles. - No network calls except package downloads for pinned tools and pinned Go modules.
- Do not lower a threshold, disable a gate, skip or delete a test.
- If the core contract (00, 03, 04, 05) lacks something this brief needs, stop with
Verdict: BLOCKED <reason>(P-11).
Design notes¶
-
Operations.
operationId Route Action Resource Class addReactionPUT /api/v1/messages/{message_id}/reactions/{emoji}reaction.addmessage rl.writeremoveReactionDELETE /api/v1/messages/{message_id}/reactions/{emoji}reaction.removereaction (message path, owner = caller) rl.writelistReactionsGET /api/v1/messages/{message_id}/reactionsmessage.readmessage rl.readpinMessagePUT /api/v1/messages/{message_id}/pinpin.createmessage rl.writeunpinMessageDELETE /api/v1/messages/{message_id}/pinpin.deletemessage rl.writelistPinsGET /api/v1/threads/{thread_id}/pinsthread.readthread rl.readAll are idempotent by HTTP semantics; none takes
Idempotency-Key. -
Order of checks for writes: normalise the symbol first (no lookup, so it cannot leak;
400); load the message with the visible predicate and callauthorize(missing and invisible share one code path,404;403with the decision's reason, for examplelevel_insufficient,capability_missing,channel_read_only); then, inside the unit of work, lock the message row (SELECT ... FOR UPDATEwith the visible predicate) and checkdeleted_at: set gives410 message_deletedforaddReactionandpinMessage, and204without change forremoveReactionandunpinMessage. The row lock serialises all reaction and pin changes of one message, so the counts in the events are exact. - Symbol rule (
SymbolNormalizer.Normalize), applied to the decoded path value and to theemojiquery filter: valid UTF-8; NFC; exactly one extended grapheme cluster after UAX #29; at mostreaction.emoji_max_bytesbytes; no code point of category Cc, Cs, Zs, Zl or Zp; none of the Bidi controls U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069; and not made only of code points of category Cf or Mn (invisible). Category Cf is otherwise allowed, because emoji ZWJ and tag sequences need it. Errors:400 validation_failedwith pointer#path/emoji(or#query/emoji) and codeinvalid_emoji. Store and return the normalised form. Reason for the byte cap: Unicode puts no bound on one grapheme cluster (combining marks stack); the cap keeps a reaction a single symbol. addReaction:INSERT ... ON CONFLICT DO NOTHING. Only when a row was inserted: appendreaction.addedwith actor = caller, scope = space, channel, thread, resource ={kind: message, id, version: current message version}, payloadReactionPayload{message_id, thread_id, emoji, principal_id, count}wherecountis the number of rows for this message and symbol after the insert.204in both cases.messages.version,trigger_state,trigger_state_atandtrigger_state_reasonstay unchanged.removeReaction: deletes only the caller's row for this message and symbol. Only when a row was deleted: appendreaction.removedwith the count after the delete.204in both cases; an invisible message gives404.- Message version (03
messages.version, 04 9 item 3, 15 5): reactions and pins do not raisemessages.version, because raising it would make an author's next edit fail with412after someone reacted. Their events carry the current message version inresource.version; the payload carries absolute values (count, the pin), so applying it twice is harmless; clients apply them inseqorder. Limits: adding a new symbol to a message that already hasreaction.max_distinct_per_messagedistinct symbols, and pinning in a thread withpin.max_per_threadpins, give400 validation_failedwith codelimit_reached(pointer#path/emojior empty, respectively); checked under the message row lock so the bound is exact. - Representation (one function for REST and events):
reactionsis a list of{emoji, count}for the message, ordered by the earliestcreated_atof each symbol, then by symbol;my_reactionslists the caller's symbols in the same order and appears only in REST responses (always present there, possibly empty);pinnedis true iff apinsrow exists. A tombstone (deleted_atset) hasreactions = [],my_reactions = [],pinned = false. Pages load summaries, own reactions and pins with one query each for all message IDs of the page. listReactions: keyset ordercreated_atascending, thenprincipal_id, thenemoji; optionalemojifilter (validated by note 3);limitwithapi.page_size_defaultandapi.page_size_max. Use the cursor codec ofinternal/platform/httpserver(M1-18), bound to the list name, the message and the filter (a cursor of another list or filter gives400 cursor_invalid). ItemsReaction{emoji, principal: PrincipalRef, created_at}withPrincipalReffrom theidentityDirectoryport in one batch call. A deleted message returns an empty page.- Deletion of a message removes its
reactionsandpinsrows in the same transaction (03messages, 20 4.2; the event payloads are redacted by M1-58). M1-58 is a dependency of this ticket: addDELETE FROM reactionsandDELETE FROM pinsfor the message at the marked step of its delete use case and the testTestDeleteMessage_RemovesReactionsAndPin. Note 7 keeps tombstones free of reactions and pins. pinMessage:INSERT INTO pins (message_id, thread_id, pinned_by, pinned_at) ... ON CONFLICT DO NOTHING;thread_idis the message's thread. Only when inserted: appendpin.addedwith payloadPinPayload{pin}. Already pinned:204without event. Any message kind may be pinned. Read-only channels allow pinning, becausemanageactions stay allowed (05 rule 1.8).unpinMessage: delete the row; only when deleted appendpin.removedwith the pin as it was.204in both cases.listPins:authorize(thread.read, thread);SELECTjoinsmessageswithm.channel_id = ANY(@visible_channel_ids)andm.deleted_at IS NULL; orderpinned_at DESC, thenmessage_id DESC; itemsPin{message_id, thread_id, pinned_by, pinned_at}; unpaginated (PinList, bounded bypin.max_per_thread). Each item passesauthorize(message.read)in filter modeauthorize.Filter(ctx)(M1-28; no denial audit, 07 4.2); a denied item is dropped, logged with only action and resource kind and counted inbothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3).- Every
SELECT,UPDATEorDELETEonreactionsorpinsjoinsmessageswithchannel_id = ANY(...); keepTestQueriesCarryVisibleChannelPredicate(M1-57) green when it is on your base. - Logs and telemetry never carry the symbol, content or display names (SEC-LOG-4).
Tests (write them first)¶
Unit:
TestSymbolNormalizer(inadapters/unicode) accepts: a thumbs-up, a thumbs-up with skin tone modifier, the ZWJ family of four, the kiss sequence of two people with two skin tones, the England subdivision flag (tag sequence), the keycap one, a single letter, the letter e with acute in composed and in decomposed form (both return the composed form). Rejects: empty string, two thumbs-up, two letters, a space, U+200F alone, U+202E alone, U+0007, U+200B alone, a lone combining mark, a letter followed by more combining marks thanreaction.emoji_max_bytesallows, invalid UTF-8.TestReactionSummaryOrder(domain).
Integration against PostgreSQL 18 (pgtest, world of 07 "Examples", HTTP through the real chain):
TestAddReaction_IdempotentSingleEvent: two identical calls give204twice, one row, onereaction.addedwithcount = 1.TestAddReaction_CountsPerSymbol: Alex and the Owner react with the same symbol, counts 1 and 2; a different symbol has its own count.TestAddReaction_ConcurrentCountsExact: parallel adds of the same symbol by several principals produce events whose counts are exactly 1 to n.TestAddReaction_IsNotStatus: on a message withtrigger_stateset, adding and removing a reaction leavestrigger_state,trigger_state_at,trigger_state_reasonandversionunchanged; changingtrigger_statethrough M1-56'sSetTriggerState(M1-56 is in this ticket's closure) creates no reaction row.TestAddReaction_Rights: a principal withreadonly gets403 forbidden; Alex on a Marketing message gets404, equal to a random UUIDv7; a read-only channel gives403 channel_read_only; an operator agent reacts in its channel.TestAddReaction_DeletedMessage410(tombstone through the delete operation of M1-58).TestReactionAndPinLimits: with small limits in the test configuration, one more distinct symbol and one more pin give400 validation_failedwitherrors[].codelimit_reached; an existing symbol can still be added by another person.TestAddReaction_InvalidSymbol400with pointer#path/emoji.TestRemoveReaction_OwnOnlyAndIdempotent: removing removes only the caller's row; a second call204without event; the event carries the count after removal.TestListReactions_PagingFilterAndPrincipals: keyset order,emojifilter,PrincipalReffields, foreign cursor400 cursor_invalid, invalid filter400, invisible message404.TestMessageRepresentation_ReactionsAndPinned:getMessageandcreateMessageresponses show the summary,my_reactionsandpinned; the payloads ofmessage.createdandmessage.updatednever containmy_reactions.TestPinMessage_RequiresManage: Alex withwritebut withoutmanagegets403; the Owner gets204; an agent gets403; Alex on a Marketing message gets404.TestPinMessage_IdempotentAndEvents: onepin.addedfor two calls; unpin twice gives onepin.removedcarrying the former pin.TestPinMessage_DeletedMessage410;TestPinMessage_ReadOnlyChannelAllowsManage.TestListPins_OrderAndVisibility: order of note 12; pins of a tombstone are absent; invisible thread404.TestReactionPinEvents_Envelope:event_logrows have resource kindmessage, the receive actions of 05 section 6, scope with space, channel and thread, andresource.versionequal to the message version.TestDeleteMessage_RemovesReactionsAndPin(note 9; M1-58 is a dependency of this ticket).
Commands that must pass:
cd services/server && go test -race ./internal/messaging/...make codegen-check,make migrations-lint,make arch,make api-lint,make config-doc-check,make licenses,make osv,make docsmake check(with the local SonarQube; if unreachable,Verdict: BLOCKED, neverSONAR_SKIP).
Acceptance criteria¶
- The six operations are registered and run
authorizewith their catalogue actions. - A reaction is one normalised grapheme cluster within
reaction.emoji_max_bytes; the cases ofTestSymbolNormalizerpass. - Reactions never change
trigger_stateormessages.version. - Repeated reactions and pins are idempotent and produce no second event; events carry exact counts.
- Pinning without
managegives403for readers and404for non-readers. - Events
reaction.added,reaction.removed,pin.added,pin.removedare appended with the receive actions of 05. - Every
Messagerepresentation carriesreactions,pinnedand, in REST only,my_reactions. -
410foraddReactionandpinMessageas inopenapi.yaml; the three configuration keys of 03 9 are in the schema and the generated reference; the limits hold. - The two new libraries are pinned, licensed as allowed, and used only in
adapters/unicode; README, doc comments, CHANGELOG and DECISIONS.md entries are present. -
make checkis green.
Definition of done¶
Code, tests and docs are complete: module README updated, a doc comment on every public symbol,
an ADR if an architecture decision was taken (none expected; rulings go to docs/DECISIONS.md), a
CHANGELOG entry under Unreleased. make check is green. An independent review and the red team
(docs/quality/red-team-gate.md) have run over the diff and their verdicts are recorded in the
report. Merge locally into the base branch only if both are green, then git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red run of each
new test), what is not proven, and follow-ups that could not be done in scope with the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or
exit=1 <reason>.