M1-62: Moving threads¶
Status: ready | Depends on: M1-54, M1-61 | Milestone: M1
Goal¶
A person with manage on the source and on the target channel moves a thread into another channel whose effective operator equals the thread's fixed operator. The thread keeps its id; its messages, read states and drafts follow in the same transaction; readers of the target get thread.moved, readers of the source get thread.moved_out; and no authorization decision after the commit evaluates the thread at its old place. This is the function 'Move thread' of 01 and the baseline rule that a thread moves only within the same operator agent.
Read first¶
docs/spec/03-data-model.md:threads(the paragraph 'Moving changes channel_id' after the indexes,operator_agent_id,moved_at, the unique index onkind = main),thread_tags(the rule that a tag belongs to the thread's channel and the last sentence),messages.channel_id,read_states.channel_id,drafts.channel_id,channels(formula of the effective operator),spaces.default_operator_agent_id, 'I1 check', 'Lock order', R-DM-11, R-DM-12, R-DM-21.docs/spec/05-actions.md:thread.movein 'Threads and agent sessions'; 'Target of a resource' (files follow their message); 'Effective level' (operator rule); rule 8; 'Decision'; rowsthread.movedandthread.moved_outin 'Durable event types and receive action'.docs/spec/04-sync-protocol.md: 'Visibility on permission changes' with 'Basic rule' (structure changes such asthread.movedare applied in log order), 'Editing and deleting' (row 'Thread moved'), 'Delivery guarantee'.docs/spec/02-architecture.md: 'Ports, events and sources per module' (chatemitsThreadMoved;messagingandsyncsubscribe), 'Permission cache', 'Transactions and Unit of Work', 'In-Tx events', 'Alignment with the core contract' (row on moving threads).docs/spec/07-permissions.md: 'Capabilities' (managecovers moving threads), 'Theauthorizefunction' (visibility before requirement), 'Validity of the cache'.docs/spec/01-product.md: 'Structure and conversations' (row 'Move thread'), 'Other rules' (Moving).docs/spec/06-api.md: 'Errors' (operator_mismatch,conflict), 'Idempotency'.docs/api/openapi.yaml:moveThread, schemasThreadMove,Thread,ThreadMovedEvent,ThreadMovedPayload,ThreadMovedOutEvent,ThreadMovedOutPayload, responseConflict.- ADRs 0017, 0018, 0021.
docs/PLAN.md: 'Way of working per ticket', M1 rows M1-53 to M1-55, M1-61, M1-65 and M1-70; 'Prerequisites from the core contract' row on 02 point 6.- Code:
internal/chat(threads M1-55, operators M1-54, channels M1-53),internal/messaging,internal/sync(M1-61),internal/authz(M1-28),internal/kernel/authorize,internal/kernel/auditing,internal/wire.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- 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.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- A thread moves only to a channel whose effective operator equals
threads.operator_agent_id; otherwise409 operator_mismatch(03threads, 01, 06 'Errors'). thread.moveneedsmanageon source and target, is audited (Audityes), uses rate-limit classrl.adminand acceptsIdempotency-Key(05, openapi).- The thread id never changes (03
threads.id). The same transaction changesmessages.channel_id,read_states.channel_idanddrafts.channel_id;cost_entriesandevent_logkeep the old channel; attached files follow their message andfiles.scope_channel_idstays (03threads, 05 'Target of a resource'). - Events
thread.moved(place: target channel, receive actionthread.read) andthread.moved_out(place: source channel, resourcechannel, receive actionchannel.read; it never names the target) (05, openapi). - After the commit no REST decision uses the old location:
authorizeresolves the thread's path at decision time (07 4.2). The gateway's permission cache appliesthread.movedin log order; that side is M1-65 (04 'Basic rule', 02 'Permission cache'). - No read permission gives
404;403only with read permission but missingmanage(05 rules 1 and 2, R-AZ-01).
Scope¶
All paths under services/server/ unless noted.
Create:
internal/chat/domain/move.go: pure ruleCanMove(thread ThreadState, source, target ChannelState, targetEffectiveOperator *uuid) errorreturning domain errors for every case in Design note 1b.internal/chat/app/move.go: the use case.internal/chat/adapters/postgres/queries/threads_move.sql: lock queries, the thread update, deletion ofthread_tagsof the thread.internal/messaging/app/subscribers.go(or the existing subscriber file):OnThreadMoved, with the queryMoveThreadMessagesininternal/messaging/adapters/postgres/queries/.- In
internal/sync/app/subscribers.go:OnThreadMoved, with the queriesMoveThreadReadStatesandMoveThreadDrafts.
Change:
internal/chatport:Threads.Move(ctx, MoveThread{ThreadID, TargetChannelID uuid; Reason string}) (Thread, error); in-transaction event typeThreadMoved{ThreadID, FromChannelID, FromSpaceID, ToChannelID, ToSpaceID uuid; ThreadVersion int64}.internal/chat/adapters/httpapi: the strict-server method formoveThread.internal/authzandinternal/kernel/authorize: the requirement forthread.movechecksmanageon the thread's path and on the target channel's path, with the second target in the resource descriptor (07 4.2 "Actions with two targets"; 02 4.5). M1-28 already provides the descriptor fieldTargetChannelID(thread.move: destination channel) and the requirementAll(Cap(manage), TargetCap(manage))for "manage on source and target"; setTargetChannelIDin the descriptor and add no second field.internal/wire: registerchat.ThreadMoved -> messaging.OnThreadMovedandchat.ThreadMoved -> sync.OnThreadMoved(in this order) and the handler; update the golden file of subscriptions.api/operations.yaml: enablemoveThread; rerun the route generator.- READMEs of
chat,messaging,sync,authz;services/server/CHANGELOG.md;docs/DECISIONS.md.
Out of scope¶
- Delivery of
thread.movedandthread.moved_outover the gateway, the hub indices and the gateway's permission cache (M1-65); this ticket writes the events and makes REST decisions follow the move. - The lint for the visible-channel predicate (AZ-P12, M1-70) and the full leak tests (M1-70).
- Moving a channel between spaces (
moveChannel, M1-53) and space reordering (M1-52). - Runs, agent sessions and costs of a moved thread (M2); none reference the channel (03
runs,agent_sessions;cost_entrieskeeps the booking channel by design). - The copy
message_attachments.channel_id(03threads, R-DM-43): M3-07 adds it with the table and afilessubscriber onchat.ThreadMovedthat changes it in the move transaction. - Changes to the core contract.
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-62-chat-thread-move; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(chat): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11).
Design notes¶
- Use case
Threads.Move, one unit of work: a. Load the thread (unknown id gives404). Build the descriptor{kind: thread, id, TargetChannelID: target_channel_id}and callauthorize(actor, thread.move, descriptor).authzdecides in this order: no read permission on the thread givesnot_found; no read permission on the target or an unknown target givesnot_foundwith exactly the body and headers of an unknown thread id; missingmanageon the source or the target givesforbidden(capability_missing). Owners havemanageeverywhere except DM channels (05 'Effective level'). b. Domain checks afterauthorize, in this order: threadkind = maingives409 conflict(each text channel keeps exactly one main thread, R-DM-11 and the unique index); target equal to the source gives400 validation_failedwith pointer/target_channel_id; archived target gives409 conflict; a DM channel as source or target gives409 conflict(not reachable afterauthorize, kept as defence in depth); effective operator of the target differs fromthreads.operator_agent_idgives409 operator_mismatch. Operators compare withIS NOT DISTINCT FROMsemantics: a thread without operator moves only into a channel without effective operator. c.reason(required byThreadMove): non-empty after trimming, no control or bidi characters (reuse the shared validatorkernel/textcheckof M1-26, SEC-IN-4), otherwise400 validation_failedwith pointer/reason. d. Locks, in this order:threadsrowFOR UPDATE; targetchannelsrowFOR SHARE; when the target inherits its operator, the target'sspacesrowFOR SHARE. Compute the effective operator only after these locks. Reason:channel.operator.setandspace.operator.setupdate these rows, so the share lock serializes the operator check against a concurrent operator change and the move cannot break I1 (03 'I1 check', target kind 2). Deadlocks are retried by the unit of work (database.tx_retry_max). e. Update the thread:channel_id = target,moved_at = now(),version = version + 1. Delete allthread_tagsof the thread: a tag must belong to the thread's channel (03thread_tags), so tags never survive a move. f. Publishchat.ThreadMoved.messaging.OnThreadMovedrunsUPDATE messages SET channel_id = $to WHERE thread_id = $thread;sync.OnThreadMovedruns the same forread_statesanddrafts. These are copies: no version change, no events from the subscribers; clients learn the new channel fromthread.moved. g. Append, in this order,thread.moved_outthenthread.moved, so a client that receives both ends with the thread in the target:thread.moved_out:scope {space_id: source space, channel_id: source},resource {kind: channel, id: source}withoutversion(the channel object did not change, so a version would make clients drop the event, 04 'Delivery guarantee' point 3), payloadThreadMovedOutPayload {thread_id, channel_id: source}, receive actionchannel.read.thread.moved:scope {space_id: target space, channel_id: target, thread_id},resource {kind: thread, id, version: new version}, payloadThreadMovedPayload {thread: the full Thread after the move, from_channel_id}, receive actionthread.read. h. Audit in the same transaction throughauditing.Recorder: actionthread.move, resource the thread, detailsfrom_channel_id,to_channel_idandreason, nothing else. Denials are audited by the authz layer like every action with Audityes(R-AZ-04). i. Answer200with theThreadafter the move.Idempotency-Keyis handled by the middleware of M1-20.
- The permission version is not raised and no
access.changedis written (05: Permissionsno). I1 needs no extra check: the target's effective operator is the thread's operator, so the target already is one of that agent's targets, and the source can only drop out (03 'I1 check'); note 1d keeps this true under concurrency. - A thread created from a main-thread message keeps
parent_message_id, which now points into the source channel; readers of the target without read permission on the source get404for that message (03threads.parent_message_id). - Permissions: REST decisions resolve the thread's channel at decision time (07 4.2) and cache no thread-to-channel resolution across requests; state that in the authz README. The gateway's cache side of
thread.movedis M1-65. MoveThreadMessageshas no visible-channel predicate by design: it writes the rows of a thread thatauthorizechecked in the same transaction (07 7.4 rule 1: writes are outside the lint). Name it in the messaging README with this reason.- Rulings for
docs/DECISIONS.md: event orderthread.moved_outbeforethread.moved; threads without operator move only to channels without effective operator; status codes for same channel (400) and archived target (409 conflict). Main threads never move, tags fall away on every move,thread.moved_outhas noresource.versionandparent_message_idstays: these are in 03 and 04.
Tests (write them first)¶
Unit:
internal/chat/domain:TestCanMovewith a table for main thread, same channel, archived target, DM source or target, equal operator, different operator, both without operator, thread with operator and a target that inherits a different space default.internal/authz:TestThreadMoveRequirement:manageon source only givesforbidden; target invisible givesnot_found;manageon both givesallow; Owner givesallow; DM participant givesforbidden.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, people from the identity test helpers; the world uses space Support with channels Allgemein and Eskalation, both with the same effective operator, and space Marketing with another operator):
TestMoveThreadHappyPath: a thread with three messages, read states of Alex and Sam, a draft of Alex and two forum tags moves from Support/Allgemein to Support/Eskalation: same id; everymessages.channel_id,read_states.channel_idanddrafts.channel_idequals the target;moved_atset;versionraised by one; no tags left.TestMoveThreadOperatorMismatch: target in Marketing gives409 operator_mismatch; nothing changed; no events, no audit allow entry.TestMoveThreadNeedsManageOnBoth:manageon the source only gives403; an invisible target gives404with the same status, body and headers as an unknown target id.TestMoveMainThreadRejected:409 conflict.TestMoveThreadSameChannel:400 validation_failed.TestMoveThreadEvents: exactlythread.moved_outfollowed bythread.movedwith consecutiveseq; thethread.moved_outrow contains the target channel id nowhere; both payloads validate against their schemas; no JSONnull.TestMoveThreadAudit: one allow entry with from, to and reason; a denied attempt by Sam produces a deny entry.TestMoveThreadAuthorizeFollowsMove: twoAuthorizerinstances with warm snapshot caches (REST mode, no log events fed): after the move a person who reads only the target getsallowforthread.readand a person who reads only the source getsnot_found, in both instances.TestMoveThreadConcurrentReadState: parallelputReadStateof Alex and the move: afterwardsread_states.channel_idequalsthreads.channel_id.TestMoveThreadConcurrentOperatorChange: parallelsetChannelOperatoron the target to another agent and the move: both requests end with a defined status (no deadlock reaches the client) andVerifyInvariantsofauthzpasses afterwards.TestMoveThreadIdempotencyKey: a replay returns the stored response withIdempotent-Replay: trueand writes no second set of events.TestSendAfterMove:createMessageto the moved thread id succeeds and the new message has the targetchannel_id(04 'Editing and deleting').
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/chat/... ./internal/messaging/... ./internal/sync/... ./internal/authz/... ./internal/kernel/... ./internal/wire/...make codegen-check,make arch,make api-lint,make api-checks,make register-checkmake check
Acceptance criteria¶
-
moveThreadis enabled and registered; AZ-P1 passes. - A different effective operator gives
409 operator_mismatch;manageis required on source and target; an invisible target gives the same404as an unknown one. - The thread id stays;
messages.channel_id,read_states.channel_idanddrafts.channel_idfollow in the same transaction; tags are removed. -
thread.moved_out(source, without target) andthread.moved(target) are written in this order and validate against their schemas. - The move is audited with from, to and reason; denials are audited.
- After the commit no REST
authorizecall in any process instance evaluates the thread at the source. - Concurrent read-state writes and operator changes leave consistent copies and I1 intact.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
chat,messaging,syncandauthzupdated (ports, events, subscriptions, the query exception of note 5, cache behaviour of note 4). - Rulings of note 6 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Merge locally into the base branch only if review, red team and
make checkare green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.