M8-11: Thread, message, pin and file items of the import interface¶
Status: ready | Depends on: M1-59, M2-18, M3-07, M3-13, M8-03 | Milestone: M8
Goal¶
After this ticket applyImportBatch writes the content of a source: a batch with thread_origin_key concerns exactly one thread (thread:<id> or channel:<id> for the main thread of a text channel) and is one transaction; its optional first item creates the thread (title source decided by the placeholder rule of M2-18, operator from planned_operator of the stored mapping, times and done state from the source, starter message link, forum tags) or, when the thread is already posted, applies only the mirrored changes of 22 12.2 step 3 (title only while title_source = auto, closed and open, last_activity_at); its message items become messages with origin = import, silent = true, no mentions rows, no trigger_state, the author mapped through the posted user item, created_at and edited_at from the source, thread_seq appended in request order, import_item_id set, reply links inside the thread and attachments from posted file items; its pin items pin messages with the importing owner as pinned_by. At the end of the batch the server sets threads.unread_floor_seq to the thread's last_message_seq and appends exactly one thread.updated (when something changed) and one import.progress, never message.created, never a notification, push, run or agent.invocation (04 15.8, 22 6.4, R-IM-05). createImportFile reserves the file ID of an attachment under its origin key (pending) with an upload session without quota, the bytes follow through two new operations of the import interface under import.apply (uploadImportFilePart, completeImportFile, so read-only mirror channels accept them, 05 rule 1.8), scan jobs of import files run with the lowest priority, a repeat returns the same ID, and the item becomes posted when the bytes are complete (22 6.5, 7.2 item 3). Why: the import tool (M8-04) and the read mirror (M8-05) copy history and new Discord messages through this path, and imported content must never trigger, notify or appear as unread (22 16 rows "Import never triggers", "No notification").
Read first¶
docs/spec/22-import-migration.md: 5.1.1 (threads: starter message, forum posts, title, times, done rule, operator), 5.3 (mentions become text), 6 (6.1 types, 6.2 fields, 6.3 content is the tool's job, 6.4 no notifications, 6.5 attachments items 3 and 5, 6.6 pins and tags), 7.2 (items 1 to 4), 8, 12.2 step 3, 16 (rows "Import never triggers", "No notification", "Archive rule", "Stored, then error"), 17 (R-IM-04, R-IM-05, R-IM-08, R-IM-11), 18 (K-IM-3, K-IM-4).docs/spec/03-data-model.md(core contract, read only): "What the import sets differently" afterimport_items,messages(origin,silent,trigger_state,import_item_id,client_msg_id,thread_seq,has_attachments,search_language),threads(operator_agent_id,title_source,parent_message_id,created_at,last_activity_at,closed_at,unread_floor_seq,last_message_seq),thread_tags,message_attachments,pins,read_states,files,file_upload_sessions, R-DM-12.docs/spec/04-sync-protocol.md15.8;docs/spec/05-actions.md(core contract, read only) rule 1.8, 4.12import.apply, 5 rule 1 (imports never trigger).docs/spec/12-files.md: 3 (upload sessions, completion, 3.4 the processing path), 10 (attachments), 11 ("Import attachments").docs/spec/09-agents.md12 (auto-title),docs/spec/14-search.md(language of imported messages).docs/api/openapi.yaml:applyImportBatch,createImportFile,ImportBatch,ImportObject,ImportFileCreate,ImportFileSlot,UploadSession,uploadPart,completeUpload,ThreadUpdatedEvent.docs/DECISIONS.md: D-IM-05, D-IM-07, D-K-I3, D-K-I4, D-W-100, D-W-120, D-W-124; the rowsD-M8-03-<n>; section 10 format.- Briefs (merged names win, D-W-100):
M8-03-import-server.md(batch framework, portschat.ImportStructure,identity.ImportPeople, origin keys,source_changed),M1-55-chat-threads.md(chat.Threads, thread tags),M1-56-messaging-send.md(insert path,thread_seq, the deferred FK),M1-59-messaging-reactions-pins.md(pins),M2-18-auto-title.md(title.Placeholders.IsPlaceholder,SourceAtCreation),M3-03-upload-sessions.md(sessions, parts, completion, quota),M3-04-clamd-scan.md(scan jobs),M3-07-attachments-invocation.md(message_attachments,has_attachments, the attach rules),M3-13-search-vectors.md(search_languageon every insert, the static insert tests). - Code on the base branch (all under
services/server/):internal/importer,internal/messaging,internal/chat,internal/files,internal/notify,internal/runs,internal/wire,api/operations.yaml.
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. Discord IDs are invented digit strings. - 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, right 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,actions.yaml) is not edited by this ticket. Everything this ticket adds is English.
Product requirements implemented here:
- Imported messages:
origin = import,kind = text,silent = true, nomentionsrows, notrigger_state,client_msg_idgenerated by the server,import_item_idset,created_atandedited_atfrom the data,thread_seqappended in request order (22 6.2, 03). origin = importnever triggers: no run, noagent.invocation, noagent.followup, no follow-up, also in a channel that has an operator and is not read-only (05 5 rule 1, 22 6.4 item 4).- No notification: no push, no notification row, no
message.created; onethread.updatedper batch and thread;threads.unread_floor_seq= end of the batch (04 15.8, 22 6.4). - Threads: title cut to
thread.title_max_chars,title_sourceby the placeholder rule,operator_agent_idfromplanned_operator(never the effective operator), times and done state from the data,parent_message_idonly when the starter message is posted, forum tags from posted tag items (22 5.1.1, R-IM-08, R-IM-11). - A posted thread changes only by the mirror rules of 22 12.2 step 3; a posted message or pin never changes (22 7.2 item 1, 12.4).
- Files:
pendingreserves the file ID before the bytes; a repeat with the same key returns the same ID;postedwhen complete; no quota; parts and completion only throughimport.apply, also in read-only channels; the normal scan and processing path;uploaded_byis the mapped author (22 6.5, 7.2 item 3, 12 11, 05 4.12). - Read-only channels accept these writes because they come through
import.apply(05 rule 1.8).
Scope¶
All paths under services/server/ unless they start with docs/. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0.
Create:
internal/importer/domain/content.goandapp/content.go: validation and application of thread, message and pin items in a thread batch (Design notes 1 to 5).internal/importer/app/files.goand the handlercreateImportFile(note 6); thefilessubscriber that marks a file itemposted(note 6 d).- Ports in the owning modules (root packages, implemented in their
app, note 7):chat.ImportThreads(create a thread with import fields, apply mirror changes, setunread_floor_seq, appendthread.updated),messaging.ImportMessages(append imported messages, link replies, attach files, pin),files.ImportFiles(reserve a file with an upload session of import mode, report completion). - Tests named under "Tests".
Change:
internal/importer/app/batch.goof M8-03: remove thenot_enabledanswers forthread,message,pinandthread_origin_key.api/operations.yaml: enablecreateImportFile,uploadImportFilePartandcompleteImportFile; regenerate.docs/api/openapi.yaml: component schemasImportThreadData,ImportMessageData,ImportPinData(note 2);ImportBatch.thread_origin_keydescription (thread:<id>orchannel:<id>);ImportFileCreategains the required membersha256(hex SHA-256 of the bytes, whichfile_upload_sessions.declared_sha256needs); new operationsuploadImportFilePart(PUT /api/v1/imports/{import_id}/files/{file_id}/parts/{part_number}, body the bytes, header digest asuploadPartdefines it) andcompleteImportFile(POST /api/v1/imports/{import_id}/files/{file_id}/complete), bothx-authz-action: import.apply,x-rate-limit-class: rl.import, with the problem codes ofuploadPartandcompleteUpload; rows in the operation table ofdocs/spec/06-api.md;createImportFiledescription (no quota, parts through the import operations);make api-lint,make api-checks,make codegen-check.internal/files: the reserve path without quota check and the part and completion use cases callable through thefiles.ImportFilesport (same rules asuploadPartandcompleteUploadof M3-03, without thefile.uploadcheck), and scan jobs with the lowest job priority for import files (note 6 c).docs/spec/12-files.md11 anddocs/spec/02-architecture.md12.2 (the priority instead of a separate queue, note 6 c);docs/spec/22-import-migration.md7.2 (the mirror changes of a posted thread, note 3) and 8 (rows ofuploadImportFilePartandcompleteImportFile, note 6 b2).- READMEs of
importer,chat,messaging,files;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M8-11-1toD-M8-11-7.
This ticket adds no table, no migration, no action, no event type, no configuration key and no top-level make target; it adds the two API operations of note 6 b2 under the existing action import.apply.
Out of scope¶
- Converting Discord content to Markdown, quote lines for replies across threads and jump links for failed attachments: the tool does it (M8-04, 22 6.3, 6.5 item 6); the server stores the given Markdown.
- Earlier session rows on threads (M8-06).
- Reactions (never taken over, 22 6.6); keeping edits in sync (22 12.4).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M8-11-import-content-items; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(importer): ...,feat(messaging): ...) 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 to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - 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, clamd only as the test container of M3-04 (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Never contact Discord or any other real service: the server never calls Discord (R-IM-01), and every test uses only fixtures and fakes (D-W-124).
- Stop only processes and containers you started, by PID or container ID. Temporary directories only via
t.TempDir(), removed at the end. - Never read or print secrets. Logs carry no content, no file name and no Discord ID.
- Time only through
kernel/clock; tests use a fake clock. - 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). - This brief was written before milestone M1 closed (D-W-100, extended to M8 by D-W-124). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Shape of a thread batch (22 8; Ruling for docs/DECISIONS.md).
thread_origin_keyisthread:<digits>orchannel:<digits>(the main thread of a posted text channel; a forum channel has no main thread,400 validation_failed). Forthread:keys the first item may be the thread item with the same key; it must be posted before or created by this batch, else409 conflictwithreasonthread_not_imported. All other items aremessageorpinitems of this thread; any other kind is400 validation_failedat/items/<i>/item_kind. Message items keep request order; the server appends them in that order and never sorts (the tool sends ascending Discord IDs, 22 6.2). -
Data schemas (
openapi.yaml, spec first, alladditionalProperties: false):ImportThreadData{channel_origin_key, name, created_at, last_activity_at, closed (boolean), closed_at?, starter_message_origin_key?, tag_origin_keys[]?}.ImportMessageData{author_origin_key, content_md, created_at, edited_at?, reply_to_origin_key?, attachment_file_ids[]?};content_mdwithinmessage.max_content_bytes(no split, 22 6.3).ImportPinData{message_origin_key}.
-
Thread item (22 5.1.1, 12.2 step 3, 03; Ruling for docs/DECISIONS.md). a. New: channel from the posted channel item (must be a channel of this source); title =
namecut tothread.title_max_chars(rune-safe);title_source = autowhentitle.Placeholders.IsPlaceholder(name)of M2-18, elsemanual;operator_agent_id= the agent ofplanned_operatorof that channel in the stored mapping (resolved throughidentity.ImportPeople.Resolve;nonegives NULL), never the channel's effective operator (R-IM-08);created_at,last_activity_atfrom the data;closedwithclosed_at(the tool applies the archive rule of 22 5.1.1, R-IM-11);parent_message_id= target ofstarter_message_origin_keywhen that message item is posted and lies in the main thread of the same channel, else NULL;thread_tagsfrom postedforum_tagitems of the same channel.kind = thread. Nothread.createdevent (the batch writesthread.updated, note 5). b. Posted: compare with the stored thread and apply only: title whentitle_source = autoand the cut name differs (the source of the title staysauto);closedtrue withclosed_at, or false (reopen,closed_atandclosed_bycleared as the reopen rule of 03 says) when the stored thread is closed;last_activity_atwhen later. Nothing else changes; the item'scontent_sha256rule of M8-03 note 5 b still applies. Update 22 7.2 with this sentence: "A posted thread applies the changes of section 12.2 step 3; every other posted item answers with its target and changes nothing." -
Message item (22 6.2, 6.4, 03; Ruling for docs/DECISIONS.md). a. Author: the target of the posted user item
author_origin_key; askippedor missing user item is409 conflictwithreasonauthor_not_imported(the tool skips messages of skipped authors, 22 5.3). b. Insert throughmessaging.ImportMessagesin the batch transaction: the fields of requirement 11;search_languageas M3-13 defines it for a message written by that author in that channel (the static insert test of M3-13 must pass); noMessagePostedin-tx event, so neitherrunsnornotifysees it (requirement 12, 13);edited_atwithout a revision row. c.reply_to_message_id= target ofreply_to_origin_keywhen that item is posted in the same thread; else400 validation_failedat the pointer (the tool writes a quote line instead, 22 6.2). d. Attachments: every ID inattachment_file_idsmust be the target of apostedfile item of this source whose file was uploaded for this channel; they attach through the attach path of M3-07 (message_attachmentswith the copiesthread_idandchannel_id,has_attachments), without+uploadchecks of the author (the import is authorized byimport.apply); at mostmessage.max_attachments. e. The new message's ID is the item's target (target_kind = message). -
End of the batch (04 15.8, 22 6.4). When at least one message, pin or thread change was written: set
threads.unread_floor_seq=last_message_seq, raise the thread version, append exactly onethread.updatedwith the thread scope; thenimport.progressand the stats counters (server.threads,server.messages,server.pins). A batch that only returned posted items writes no event at all (22 7.2 item 1). -
createImportFile(22 6.5, 7.2 item 3, 12 11; Ruling for docs/DECISIONS.md). a. Authorizeimport.applyon the source;channel_idmust be a channel of this source;author_idmust be the target of a posted user item of this source;size_bytesat mostfile.max_upload_bytes; origin keyfile:<digits>. b. New key: write the itempendingwithtarget_kind = fileand the reserved file ID, and create in the same transaction thefilesrow (upload_state = pending,purpose = attachment,uploaded_by= the author,scope_channel_id= the channel,original_namesanitized,declared_mime_type) and itsfile_upload_sessionsrow exactly as 03 defines it (mode = direct,declared_size_bytes,declared_sha256from the request,part_size_bytesfromfile.upload_part_bytes), without the quota check ofcreateUpload(12 11). No core column is added: the session belongs to the import because its file is the target of apendingfile item of the source. ResponseImportFileSlot{item, upload}. b2.uploadImportFilePartandcompleteImportFile(new, spec first): authorizeimport.applyon the source (owner,running); the file must be the target of a file item of this source, else404; then the same part checks (digest, length,409 conflictwithreasonpart_conflict) and completion checks (missing parts, SHA-256 againstdeclared_sha256) as M3-03, through thefilesport. These routes exist becauseuploadPartandcompleteUploadarefile.upload, a write action that rule 1.8 refuses in a read-only mirror channel, while 05 4.12 puts files of an import underimport.apply. c. Processing after completion is the normal path of 12 3.4 (checksum, type, scan, metadata, thumbnails). 12 11 asks for a separate queuefiles_bulk; this ticket instead enqueues the scan and processing jobs of import files on the queuefileswith the lowest River job priority, so live uploads (default priority) are always fetched first, and needs no new queue and no new configuration key. Update 12 11 and 02 12.2 with this rule. d. Repeat with apendingkey: return the same file ID; when its upload session is still open, return it; when it expired or was aborted, open a new session for the same file ID. Repeat with apostedkey: return the item withoutupload. When the upload completes (the in-tx event offilesfor a completed upload, or the port call the merged code offers), the importer marks the itempostedin the same transaction. A file whose scan endsinfectedstayspostedas an item but cannot be attached (M3-07 rules); the tool reports it as failed with the jump link. -
Ports (03 principle 12; Ruling for docs/DECISIONS.md).
chat.ImportThreads.Create(ctx, ImportedThread) (ids.ID, error),ApplyMirror(ctx, threadID, MirrorChange) (changed bool, err error),FinishBatch(ctx, threadID) error(floor, version,thread.updated);messaging.ImportMessages.Append(ctx, threadID, []ImportedMessage) ([]ids.ID, error),Pin(ctx, messageID, pinnedBy ids.ID) error(no per-message event);files.ImportFiles.Reserve(ctx, ReserveSpec) (FileSlot, error),SlotFor(ctx, fileID),UploadPart(ctx, fileID, partNumber, digest, body),Complete(ctx, fileID). Names may differ in merged code (D-W-100); the behaviour may not.
Tests (write them first)¶
internal/importer/domain:TestThreadBatchShape(note 1 rules);TestMirrorChange(table: auto title renamed, manual title untouched, archive and reopen, olderlast_activity_atignored).- Integration tests with
pgtestand the HTTP chain (seed of M8-03 plus a posted text channel withlead = bothy, not read-only, operator Lotse; a read-only forum channel withlead = discord; users Alex, Sam and a placeholder):TestImportNeverTriggers(plan acceptance, 22 16): a message batch in the channel with operator Lotse creates no run, noagent.invocation, no follow-up, notrigger_state.TestImportNoNotification(22 16): no notification row, no push job, nomentionsrow, nomessage.createdin the event log; exactly onethread.updatedper batch;unread_floor_seqequalslast_message_seq; Alex's unread count for the thread is 0.TestThreadItemFields: placeholder name givesauto, a real namemanual; operator fromplanned_operatorwhile the channel has no operator; times andclosed_atfrom the data; starter message link only when posted.TestMirrorChangesOfPostedThread(plan acceptance): rename withautochanges the title, withmanualnot; archive and reopen;thread.updatedonly when changed.TestRepeatBatchWritesNothing(plan acceptance): the same thread batch twice gives the same targets and no event the second time.TestStoredThen502ThreadBatch: the response is dropped after commit; the retry gives the same message IDs and the samethread_seqvalues.TestMessageOrderAndSeq:thread_seqfollows request order, notcreated_at.TestAuthorNotImported,TestReplyTargetRules,TestAttachmentRules(a file of another channel, apendingfile, more thanmessage.max_attachments).TestPinsImported:pinned_byis the importing owner; no per-message event.TestReadOnlyChannelAcceptsImport: the read-only forum accepts the batch.TestImportFileInReadOnlyChannel: parts and completion through the import operations succeed in a read-only mirror channel, whereuploadPartof the owner is refused with403 channel_read_only.TestCreateImportFileSameID(plan acceptance): three calls with one key return one file ID; an aborted session gets a new session for the same ID; after completion the item ispostedand the call returns noupload; no quota is charged.TestImportFileScanPriority: the scan job of an import file has the lowest priority; a live upload enqueued later is fetched first by the worker.TestSearchLanguageOnImportedMessage(M3-13 static and behaviour test pass).
Commands that must pass, from the repository root:
cd services/server && go test ./internal/importer/... ./internal/messaging/... ./internal/chat/... ./internal/files/... -race -count=1make arch,make api-lint,make api-checks,make codegen-check,make lint,make fmt-check,make coveragemake check
Acceptance criteria¶
-
origin = importnever creates a run or an invocation, also in a channel with an operator. - No push, no
mentions, nomessage.created; onethread.updatedper batch; imported content is not unread. - Repeating a batch writes nothing; a dropped response after commit gives no duplicate.
- A file item reserves one ID across repeats and becomes
postedwhen complete. - Mirrored title and done state follow the source by the rules of 22 12.2.
- OpenAPI, 12, 02, 22, READMEs, CHANGELOG and rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comments on every exported Go symbol; comments explain why.
- READMEs of the touched modules updated in English.
- Rulings of Design notes 1, 3, 4, 6 and 7 recorded in
docs/DECISIONS.mdsection 10. 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, 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. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are 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, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.