M8-04: Import tool: dry run, apply, report¶
Status: ready | Depends on: M8-02, M8-10, M8-11 | Milestone: M8
Goal¶
After this ticket bothy import discord takes over the history of a Discord server into Bothy. Without --apply it is a dry run that only reads (Discord through the client of M8-01, Bothy through the client of M8-10), writes nothing anywhere, not even a local file, and prints the check report of 22 11. With --apply it imports into the source of this guild that the owner created and started in the web console: preflight check (22 4.4), mapping of categories, channels, forums, threads, people and tags (22 5), conversion of every message to the Markdown dialect of the server (22 6.3), attachments through the CDN fetch of M8-02 and createImportFile with a jump-link line when they cannot be taken over (22 6.5), batches per thread in Discord order through applyImportBatch, the cursor advanced only after a successful batch, a run lease that keeps two writing runs apart unless --takeover, and the report stored in the source's counters. --channel and --only-thread narrow the run and an unknown or non-matching ID ends it before any write; init-map writes a skeleton of the mapping file; status prints source, state, ownership list and counters. The tool's own values come from --config (nested YAML with import.*) and the environment BOTHY_IMPORT__..., which wins. Exit codes follow 22 10.3. An abort between a batch and its cursor never produces a duplicate: a new run takes every item over exactly once, in Discord order per thread. Why: this is the import itself; its guarantees (GET only in the dry run, no duplicates, no secrets in output, no trigger) are the exit criteria of M8 (PLAN 14, 22 16, ADR 0043).
Read first¶
docs/spec/22-import-migration.md: all of sections 1 to 11, 14, 16 and 19 (the mirror of 12 and the switch-over of 13 are M8-05 and M8-07, the session binding of 9 is M8-06, but read them to leave room).docs/adr/0043-discord-import-read-only-dry-run-origin-key.md;docs/REUSE.md4.1, 4.3, 4.4, 4.7, L17, L18, L19, L20;docs/RISKS.mdRK-73, RK-76.docs/operations/configuration.md: section 2 (naming rule, formats), section 4 "22 Import and migration from Discord";docs/DECISIONS.mdD-W-51 (tool values never in the server file).docs/spec/02-architecture.md3.3 (exit codes and output of commands);docs/spec/15-client-architecture.md11 (in-app paths used for channel links);docs/spec/06-api.md7.docs/api/openapi.yaml: the tagImport,getMe,getInstance(InstanceLimits),listUsers,listAgents,listSpaces,listSpaceChannels,uploadImportFilePart,completeImportFile, and the data schemasImport*Dataadded by M8-03 and M8-11.docs/DECISIONS.md: D-IM-01 to D-IM-08, D-W-100, D-W-120, D-W-124; the rowsD-M8-01-<n>,D-M8-02-<n>,D-M8-03-<n>,D-M8-10-<n>,D-M8-11-<n>; section 10 format.- Briefs (merged names win, D-W-100):
M8-01-discord-read-client.md,M8-02-cdn-fetch.md,M8-10-import-login-client.md(session, transport,bothytest,bothy importcommand group),M8-03-import-server.md(states, mapping schema, structure items,source_changed, cursor and stats namespaces),M8-11-import-content-items.md(thread batch shape, message and file items),M1-07-config-schema-loader.md(platform/configNewSchema,Load,Sources),M1-71-contract-tests.md(harness.Start). - Code on the base branch:
services/server/cmd/bothy,services/server/cmd/bothy/importtool/...,services/server/internal/platform/config,services/server/api/restclient,services/server/test/.
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 fixtures use invented IDs and names. - 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 updates
docs/spec/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:
- Dry run is the default: only
GETagainst Discord and Bothy (plusrefreshTokenwhen the access token expired), no attachment download, no file written except the rotated session file of M8-10, no source created (22 10.1, 10.2 rules 1 and 5, R-IM-02, ADR 0043 ruling 2). --applyimports only into therunningsource of this guild that the owner created in the web console; the stored mapping is authoritative and the local--mapmust equal it apart fromleadvalues; noimport.managecall from the tool (22 8, D-W-124).- Preflight of 22 4.4 before every run, including the dry run: bot token valid, server and mapped channels readable, intent probe, active owner session, every target person and agent exists.
- Mapping and conversion exactly as 22 5 and 6 (types, fields, content table, attachments, pins and tags, reactions only counted).
- Idempotency and resuming as 22 7: origin keys, cursor only after a successful batch, run lease with
--takeover, every item at most once and in Discord order per thread. - Options and exit codes as 22 10.1 to 10.3: an unknown or non-matching ID in
--channelor--only-threadends the run before any write, also for the valid IDs of the same call; contradictory options end with exit2without any call. - Output (standard output, standard error, report, log) never contains message content, file names of attachments, names of people, tokens or signed URLs (22 10.2 rule 4, 11).
- Configuration:
--configreads one YAML file with nestedimport.*keys; flat dot keys and keys outsideimport.*are errors; the environmentBOTHY_IMPORT__...takes precedence; the server file is never read (22 19, D-W-51). - Telemetry of the tool is counters and durations in the report and in
import_sources.stats(namespacetool.*), no content, no Discord IDs (22 14).
Scope¶
All paths under services/server/ unless they start with docs/. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0.
Create (package cmd/bothy/importtool unless named otherwise):
config.go: the schema of the tool keys of 22 19 overplatform/config, loading from--configand the filtered environment (Design note 2).mapping.go: strict YAML parse ofimport_source.discord.v1, validation without calls, comparison with the stored mapping (note 3).preflight.go(note 4),plan.go(structure and thread plan, note 5),convert.go(content conversion, note 6),attachments.go(note 7),apply.go(batches, cursor, lease, note 8),report.go(note 9),initmap.go(note 10),status.go,run.go(the command flow and exit codes, note 1).cmd/bothy/importtool/bothyapi/bothytest: extend the fake of M8-10 with an in-memory import interface (note 11).cmd/bothy/importtool/discord/testdata/: extra fixtures where the conversion table needs them.services/server/test/importtool/: real-server tests with the harness of M1-71 and the fake Discord (note 12).- The subcommand
bothy import discord(flags of note 1) in thebothy importgroup of M8-10;importtool/README.md(purpose, commands, options, exit codes, configuration, the dry-run guarantee, an example mapping with white-label data).
Change:
docs/spec/22-import-migration.md: 6.3 (the channel link form of note 6), 10.1 (the--session-fileoption and thatstatusprints no names), 11 (the JSON form of the report, note 9); no other section unless a gap is found (then report it).services/server/README.md,services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M8-04-1toD-M8-04-10.
This ticket adds no API operation, no table, no action, no event and no server configuration key.
Out of scope¶
--followand--once(read mirror, M8-05),--sessions(M8-06),switchandrevert(M8-07): the flags do not exist yet; their tickets add them and their contradiction rules.- Creating, starting, pausing, completing a source and lead changes (web console, M8-08).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M8-04-import-tool; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(import): ...) 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).
- Never contact Discord, its CDN or any real service: Discord and the CDN are the fakes of M8-01 and M8-02; Bothy is the fake of M8-10 or the harness server of M1-71 on
127.0.0.1with port 0, PostgreSQL throughpgtest(P-09, P-10). Without a Docker-compatible runtime the real-server tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
t.TempDir(), removed at the end. - Never read or print secrets; tokens in tests are invented strings.
- 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¶
-
Command and flow (22 10).
bothy import discord --guild <id> --map <file> [--apply] [--channel <id>]... [--only-thread <id>]... [--report text|json] [--takeover] [--discord-token-file <path>] [--config <file>] [--session-file <path>], plusinit-map --guild <id> --out <file>andstatus --guild <id>. Order of a run: (a) parse flags and check contradictions (--takeoverwithout--apply,--reportwith another value, a non-digit ID) with exit2and no call; (b) load configuration (note 2) and the token (environmentBOTHY_IMPORT_DISCORD_TOKENor--discord-token-file; both or neither is exit2; a token file readable by group or others is refused like the session file of M8-10); (c) parse and validate the mapping without calls (exit2); (d) preflight (note 4; exit2on a finding); (e) with--apply: find the source, compare mappings, take the lease (note 8; exit3when held); (f) plan, then read and write (or only read); (g) report (note 9). Exit1for network, server error, rate limit above the limit or Bothy409 conflictwithreasonimport_not_runningduring the run (the owner paused it); the state stays and a new run resumes. Report and log lines go to standard error; the report goes to standard output (text or JSON). -
Configuration (22 19, D-W-51; Ruling for docs/DECISIONS.md). Reuse
platform/configwith a schema of exactly the keys of 22 19 (andimport.login_poll_intervalof M8-10). Environment: pass only variables whose name starts withBOTHY_IMPORT__toLoad(a server variable on the same machine must not abort the tool, andBOTHY_IMPORT_DISCORD_TOKENis the token, not a key); file:--config(neverBOTHY_CONFIG_FILE); the loader already rejects dotted keys, unknown keys and duplicates.import.attachment_max_byteswithout a value meansfile.max_upload_bytesof the server (getInstancelimits); a configured value above the server limit is lowered to it and the report says so. Test the precedence (environment over file over default) and that a key outsideimport.*in the file is an error. -
Mapping file (22 5.2). Strict YAML (
yaml.v3withKnownFields(true)through the vendor group the config package uses): unknown field, wrong type, duplicate Discord ID in a list, non-digit ID, a target in none of the allowed forms,guild_iddifferent from--guildare exit2with the YAML line, before any call. Unknown handles are found in the preflight (they need a call) and are also exit2. With--apply, the source of this guild (listImports,kind = discord,config.guild_idequal; more than one source in statedraft,runningorpausedfor one guild is exit2) must berunning(else exit2with "start the source in the web console"), and its stored mapping must equal the parsed--mapafter normalization exceptlead(else exit2naming the first differing JSON pointer); the leads come from the stored mapping, never from the file (22 12.1). -
Preflight (22 4.4, RK-73). (1)
Me:bot = true, else exit2. (2)Guild,GuildChannels: every mapped category and channel exists; a mapped channel the bot cannot read (403on its first message page) is exit2. (3) Intent probe: in the first readable mapped channel in Discord order with messages by other authors, among the firstimport.discord.preflight_samplemessages by authors other than the bot at least one has content, an attachment or an embed; otherwise exit2with the note thatMESSAGE_CONTENTis probably missing (22 16 row "Intent probe"). (4)getMe: the session's person is an active owner, else exit2. (5) Everyuser:<handle>andagent:<handle>target and everyplanned_operatorexists and is active (listUsers,listAgents), else exit2. (6) Name collisions: acreatename equal to the name of an existing space or channel (listSpaces,listSpaceChannels) that is not the target of a posted item of this source (listImportItems, when the source exists) is exit2before the first write (22 5.1 rule 1). -
Plan (22 5.1, 5.1.1, 5.3, 6.1). Structure in Discord
positionorder (spaces, then channels per space, then forum tags per forum), skipped targets and voice or stage channels counted; channels without category perdefaults.uncategorized_space. Threads per channel: active list plus archived public and, where allowed, private lists (a403there is a note, not an abort); the done rule of 22 5.1.1 withimport.discord.closed_tags(onlyarchivedexactlytrueand not in the active list, or a closed tag); title from the name; starter message keymessage:<thread_id>for threads of text channels. Order of writing per channel: main thread messages first, then threads by ascending ID, each thread's messages ascending from its cursor. People: before a thread batch the tool posts the user items of all authors of that batch that are not yet posted (a structure batch withoutthread_origin_key), honoringdefaults.unmapped_human,defaults.unmapped_botand webhook authors (22 5.3); messages of a skipped author are counted and skipped.--channelnarrows to these channels (and their structure);--only-threadselects exactly these threads (and their channel and structure); every given ID must be a mapped channel or a thread of a mapped channel found in the reads, else exit2before the first write (22 10.2 rule 2, 16 row "Invalid selection"). -
Conversion (22 6.1 to 6.3, 5.3; Ruling for docs/DECISIONS.md). Pure functions with table tests: types 0, 19, 20, 21, 23 taken over; others counted as skipped by type; type 21 dropped when its starter is in the main thread; empty messages without attachment, embed, sticker and poll skipped.
<@id>and<@!id>become@DisplayNamefrom the message'smentions(fallback@unknown-user), never a mention row;<@&id>@RoleNamefrom the guild roles (GET /guilds/{id}carriesroles; fallback@unknown-role);<#id>a Markdown link[#name](/c/<channel_id>)with the in-app path of 15 11 when the channel is posted in this source, else#name;<:NAME:ID>and<a:NAME:ID>:NAME:;<t:SECONDS:STYLE>the ISO 8601 UTC text; embeds a quote block with title, link and description (no image is loaded); stickers[Sticker: name]; polls question and answers as a list with the line "Poll"; forwardedmessage_snapshotsa quote block; a leading small-text marker (-#and one space) per line removed; replies (type 19) getreply_to_origin_keywhen the referenced message is posted in the same thread or earlier in the same batch, else a first line> @Name: <text>cut toimport.reply_quote_max_chars.content_sha256is the hex SHA-256 of the converted Markdown plus the sorted attachment IDs. Every message stays one message (22 6.3, last paragraph); a converted text abovemessage.max_content_bytesof the server is a reported failure of that message, never a split. -
Attachments (22 6.5). Per attachment: size above the effective
import.attachment_max_bytesis "too large"; else fetch through M8-02 into memory (bounded by the limit plus one byte, never a local file) and compute the SHA-256; thencreateImportFile(origin keyfile:<attachment_id>, the target channel, the mapped author, the file name, size, content type,sha256); when the slot has anupload, send the parts withuploadImportFilePart(part size and digests as the session prescribes) and finish withcompleteImportFile(M8-11 note 6 b2); apostedslot is reused without fetching again when the item is alreadyposted(checklistImportItemsor the slot before fetching on a resumed run). On failure or "too large" the message gets one line per attachment: file name, size, reason and the jump linkhttps://discord.com/channels/{guild_id}/{channel_id}/{message_id}(22 6.5 item 6); the report counts it. The file name is message content and never appears in output. -
Apply, cursor and lease (22 7.3, 7.4; Ruling for docs/DECISIONS.md). Cursor object
{version: 1, run: {id, renewed_at}, channels: {<id>: <last message id>}, threads: {<id>: <last message id>}}. Start: read the source and itscursor(getImport, membercursoradded by M8-03); whenrun.renewed_atis younger thanimport.lease_stale_afterandrun.idis not ours, exit3unless--takeover; write the lease withsetImportCursor. Renewal does not depend on batches: a ticker renews the lease everyimport.follow.poll_intervalwhile the run lives, serialized with the batch's own cursor writes (one mutex), so long rate-limit waits, large attachments or a backoff never let it age pastimport.lease_stale_after. Before every renewal and every batch the run re-reads the lease; when another run ID holds it (a--takeover), the run stops writing at once and ends with exit3(22 7.4), so a stale loop never overwrites the cursor of the run that took over. Each batch: at mostimport.batch_max_itemsitems (and never more than themaxItemsofImportBatch; a configured value above it is lowered and reported), one thread; after a2xx, update the cursor of that channel or thread to the last message ID of the batch and renew the lease in the samesetImportCursorcall, together with the tool counters (tool.*). A5xx, a network error or a lost response: the run ends with exit1without advancing (the next run repeats the batch; the server answers posted keys with their targets, M8-03 note 5). At the end the lease is cleared (runremoved). A test hookafterBatch func(batchNo int) error(unexported, set only by tests) lets the crash test end the process between batch and cursor. -
Report (22 11; Ruling for docs/DECISIONS.md). One row per channel (Discord channel name and Bothy target name, which are structure names, not people) and a total: threads open and done; messages taken over, already present, skipped by reason; attachments taken over, too large, failed; people mapped and placeholders (counts only); sessions (left empty until M8-06); pins and tags; reactions not taken over (sum of counts); notes (
403, missingMANAGE_THREADS,permission_overwritespresent, changed at the source fromsource_changed, attachment limit lowered).--report jsonprints the same as one JSON object with aversionmember. A dry run says "would take over". The totals are also written intostats.toolwith the lastsetImportCursorof an--applyrun (no write in a dry run). -
init-map(22 5.2). Reads structure and all messages (like a dry run, with the same preflight steps 1 to 3) and writes exactly the file--out(refusing to overwrite an existing file, exit2): every category and channel withtarget: {create: {name: <Discord name>}},planned_operator: none,lead: discord, voice and stage channels as comments, every author seen withtarget: placeholderand a YAML comment of the form# botor# webhook(no names in comments),defaultsfrom 22 5.2. It writes no other file. -
Fake Bothy import interface (extend
bothytest). In memory: sources, items with origin keys (postedreturns the same target,source_changedon a different hash), structure and thread batches with the shape rules of M8-11, files with slots and upload sessions, cursor and stats,getMe,getInstance,listUsers,listAgents; method counting per route; faults "stored, then 502",409 conflictwithreasonimport_not_running,429. It is only for unit tests; the guarantees that need the real server run in note 12. -
Real-server tests (
test/importtool, componenttestsupport).harness.Startwith Robin as owner; the test creates and starts the source with Robin's step-up session (the web console's job in production), logs the tool in through pairing (M8-10), and runs the tool in-process against the fake Discord fixtureguild_basic.json.
Tests (write them first)¶
importtool/config_test.go: precedence; flat dot key rejected; key outsideimport.*rejected; a server variable such asBOTHY_DATABASE__URLin the environment is ignored;BOTHY_IMPORT_DISCORD_TOKENis not a key.importtool/mapping_test.go: one row per error of note 3, each exit2before any call (both fakes log no request).importtool/convert_test.go: one row per line of the 22 6.3 table, 6.1 type rules, reply quote cut,content_sha256stability.importtool/dryrun_test.go(plan acceptance, 22 16 row "Dry run only GET"; REUSE 4.7): with a fresh access token both fakes log onlyGET; with an expired one Bothy additionally logs exactly onerefreshToken; the working directory (t.Chdirinto an emptyt.TempDir()) andTMPDIR(another emptyt.TempDir()) are empty afterwards; the session file changed only in the refresh case; the CDN fake logs no request.importtool/selection_test.go(plan acceptance, 22 16 row "Invalid selection"): an unknown--only-threador--channelamong valid ones ends with exit2and the Bothy fake logs no write.importtool/preflight_test.go: intent probe exit2(22 16 row "Intent probe"); non-bot token; not an owner; unknown handle; name collision.importtool/secret_test.go(22 16 row "No secret in output"): with echoing fakes and a fixture with distinctive message texts, file names and display names, none of token, signed query, message text, file name or display name appears in standard output, standard error, report (text and JSON) or debug log.test/importtool/apply_test.goagainst the real server:TestApplyImportsFixture(counts equal the fixture: spaces, channels, forum tags, threads open and done per the archive rule, messages withorigin = importandsilent, pins, placeholders; attachments over the limit get the jump-link line);TestCrashBetweenBatchAndCursor(plan acceptance, 22 16 row "No duplicate after a crash": the hook ends run 1 after batch N before the cursor; run 2 completes; every origin key exactly once inimport_items, each thread's messages inthread_seqorder equal to Discord ID order, no duplicate message rows);TestRunLease(a second run while the lease is fresh exits3; with--takeoverit runs; with a fake clock, a run that waits longer thanimport.lease_stale_afterin a rate-limit wait still holds a fresh lease; a run whose lease was taken over stops before its next batch with exit3); the crash test lets run 2 use--takeoveror advances the fake clock beyond the window, because run 1 left a lease;TestPausedSourceExit1;TestMappingDiffersFromStoredExit2;TestImportedMessagesNeverTrigger(a channel imported withlead = bothyand an operator gets no run).
Commands that must pass, from the repository root:
cd services/server && go test ./cmd/bothy/... ./test/importtool/... -race -count=1make arch,make lint,make fmt-check,make semgrep,make coveragemake check
Acceptance criteria¶
- Dry run: the fakes see only
GET(plus one refresh when the token expired); no file in the working or temp folder. - An unknown
--only-threadaborts before any write. - Crash between batch and cursor: every item exactly once, order per thread equal to the source.
-
--configreads nested YAML withimport.*, rejects flat dot keys and keys outsideimport.*; the environmentBOTHY_IMPORT__...takes precedence. - Preflight, conversion, attachments, report and exit codes follow 22.
- No content, file name, person name, token or signed URL in any output.
- README, 22, 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.
cmd/bothy/importtool/README.mdin English.- Rulings of Design notes 2, 3, 6, 8 and 9 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 (the tool never ran against a real Discord server; the conversion follows the cited documentation).
- 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>.