M3-11: Channel export¶
Status: ready | Depends on: M1-57, M3-05 | Milestone: M3
Goal¶
A person with the level read and the capability export on a channel, acting in a session with a fresh step-up, downloads the history of that channel for a time range: GET /api/v1/channels/{channel_id}/export?from=...&to=... (exportChannel) streams one Message per line as application/x-ndjson, thread by thread in thread_seq order, built by the same representation as the history of M1-57. The export contains only what the person may read at the moment each line is sent: every query carries the visible-channel predicate, every line passes authorize(message.read), deleted messages appear only as tombstones, and a revocation during the stream ends it visibly. The start of every export is in the audit log before the first byte leaves the server, denials are audited by authz, and the rate-limit class rl.export throttles it. This is the function "Export of a channel" of 01 and the action export.channel of 05; the desktop channel menu (M4-33) builds on it.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 9 "M3 Platform services" (Goal, Scope, Out of scope, Exit criteria, Test strategy, Red team, Risks, the row M3-11 and the neighbouring rows M3-05, M3-07, M3-10 and M3-12); section 10 row M4-33; section 13 rows M7-06 and M7-08; section 7 rows M1-19, M1-50, M1-57, M1-69, M1-70 and M1-71.docs/briefs/README.md("How a brief runs") and the briefs this ticket builds on:M1-57-messaging-history.md(read path,MessageView, tombstone representation,TestQueriesCarryVisibleChannelPredicate),M1-56-messaging-send.md(theMessagerepresentation),M1-28-authz-core.md(Authorizer.Authorize,Authorizer.Scope,authorize.Filter,authorize.WithRequestScope, the requirement for "read and +export", the order of checks,writes(a)),M1-35-identity-step-up.md,M1-34-identity-sessions.md(RevokeSession),M1-24-audit-chain.md(auditing.Recorder,Entry,Attrs),M1-18-http-chain.md(chain, deadlines, streamed bodies, proof of the check, recover stage),M1-17-api-server-stubs.md(api/operations.yaml, strict server,api/routes),M1-19-rate-limits.md(classrl.export),M1-50-audit-verify.md(the NDJSON exportexportAuditEvents, same pattern),M1-53-chat-channels.md(archiveChannel),M1-13-unit-of-work.md(txn.UnitOfWork), and the M3-05 briefM3-05-file-download.md(streamed downloads and their deadline).- Dependencies implied through M1-57 and M3-05 and therefore not in the header: M1-56, M1-55, M1-53, M1-35, M1-34, M1-28, M1-24, M1-18, M1-17, M1-13, M1-12 (
pgtest), M3-04, M3-03, M3-01. M1-19, which this ticket needs (Design note 1), is in the dependency closure too. docs/spec/01-product.md: "3.4 Files and voice" (row "Export of a channel").docs/spec/02-architecture.md: "4.3 Ports, events and sources per module" (messagingportExport), "4.4 Allowed dependencies", "4.5 Cross-cutting ports (internal/kernel)", "4.6 Actions per module" (export.channelbelongs tomessaging; the bullet "filesstores the package", Design note 12), "8.5 What never happens in a transaction", "9.1 Chain in the api node", "9.2 404 instead of 403", "9.3 Proof of the check", "14.1 Error kinds", "14.2 Deadlines", "15.3 Shutdown".docs/spec/03-data-model.md: "1. Principles" items 1, 2, 7, 12 and 13; tablemessages(indexes(channel_id, created_at DESC)and unique(thread_id, thread_seq)); tableaudit_events; R-DM-20.docs/spec/04-sync-protocol.md: "4. Thread sequence", "8.1 Basic rule".docs/spec/05-actions.md: "1. Binding rules" rules 1, 2, 3 and 9; "2.3 Effective level"; 2.4 (the columns Owner, Step-up, Audit and Permissions); "2.5 Decision"; "2.7 Effect (effect)"; "3. Rate-limit classes"; rowexport.channelin "4.12 Import, costs, privacy, status"; R-AZ-01 to R-AZ-04 and R-AZ-06 in "8. Decisions of this chapter"; "10. Configuration values of this chapter".docs/spec/06-api.md: "2. Basic rules" (Media types, Nonull, Caching, Time), "3.3 Step-up", "4. Authorization in the API", "5. Errors", "9. Rate limits", "15. Contract tests and gate" (G7, G8), "16. Endpoints" > "Privacy".docs/spec/07-permissions.md: "2.1 Subjects" (agents and apps never getexport), "2.4 Capabilities", "4.1 Decisions and reasons", "4.2 Flow" (visibility before requirement before step-up; the use case writes the allow entry), "4.3 Visibility", "4.5 Archived spaces and channels" ("The data stays for retention and export"), "7.4 Query pattern" rules 1 and 3, "8.2 Immediate effect on revocation", "10. Examples", "11.2 Required tests" (AZ-P1, AZ-P4, AZ-P9, AZ-P12, AZ-P13).docs/spec/08-identity.md: "6.2 Step-up flow", "15. Tests" (ID-T18).docs/spec/19-security.md: "3. HTTP security headers" (SEC-HDR-1, SEC-HDR-2), "9. Logs without secrets and without content" (SEC-LOG-3, SEC-LOG-4), "12. Supply chain, backups and audit integrity" (SEC-AUD-4, SEC-AUD-5), "14. Tests" (SEC-T03, SEC-T10).docs/spec/actions.yaml: entryexport.channel(right: read and +export,step_up: true,audit: always,rl_class: rl.export,effect: write).docs/api/openapi.yaml: operationexportChannel(parameterChannelIdPath, query parametersfromandtowith schemaTimestamp,200asapplication/x-ndjsonwith schemaMessage, responsesBadRequest,Unauthorized,Forbidden,NotFound,TooManyRequests,Error); schemasMessage,Problem,FieldError; for comparisonexportAuditEvents.- ADRs in
docs/adr/: 0008, 0013, 0015, 0017, 0018, 0020, 0021, 0049, 0053, 0065. docs/security/threat-model.md: T-AZ-01, T-AZ-02, T-AZ-03, T-AZ-04.docs/RISKS.md: RK-02.docs/OPEN-QUESTIONS.md: OQ-19 (direct messages stay invisible to owners; default yes).docs/observability/signals.md:bothy.authz.errorswithbothy.reason = filter_mismatch;docs/observability/alerting.md:BothyAuthzError.docs/operations/configuration.md:ratelimit.rl.export.per_minute,ratelimit.rl.export.burst,auth.step_up_max_age,api.page_size_max,message.list_content_max_bytes,http.request_timeout,http.transfer_timeout,database.statement_timeout,database.idle_in_tx_timeout,audit.deny_aggregate_window,authz.not_found_timing_tolerance.- Code:
services/server/internal/messaging(M1-56, M1-57),internal/kernel/authorize,internal/kernel/auditing,internal/kernel/txn,internal/kernel/actor,internal/kernel/ids,internal/authzwith its fakes ininternal/authz/adapters/fake,internal/platform/httpserver,internal/platform/ratelimit,internal/platform/pg/pgtest,internal/wire,services/server/api/operations.yaml,services/server/api/routes, and the download handlers of M3-05 ininternal/files/adapters/httpapi(read only, for the streaming pattern).
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, 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) is not edited by this ticket.
Product requirements implemented here:
export.channelneeds the levelreadand the capabilityexporton the channel (M1-28 maps "read and +export" toAll(Level(read), Cap(export))), a step-up not older thanauth.step_up_max_age, writes allow and deny to the audit log, and uses the rate-limit classrl.export(05 rowexport.channeland 2.4; 07 2.4;docs/spec/actions.yaml).- No read right gives
404 not_found, identical for a foreign and an unknown channel id; read right withoutexportgives403 forbiddenwithreasoncapability_missing; read andexportwithout a fresh step-up give401 step_up_required; a principal without a session of a human (agent token, app token, personal API token) is refused with403 forbidden(05 rule 2, 2.4 and 2.5; 07 4.2; R-AZ-01, R-AZ-03; 06 3.3; ID-T18). - Owners have
exporton every channel except DM channels, and only in a session; agents and apps never haveexport; DM participants havewriteandupload, notexport(05 2.3 items 1, 3 and 5; 07 2.1; R-DM-20; R-AZ-02; R-AZ-14; OQ-19). exportChanneltakes the required query parametersfrom(inclusive) andto(exclusive) and answers200asapplication/x-ndjsonwith oneMessageper line inthread_seqorder per thread; the body streams (06 "Basic rules", Media types; openapiexportChannel).- Only visible content: every SQL statement of the export carries
channel_id = ANY($visible)with the list fromAuthorizer.Scope, and every line passesauthorize(message.read)in filter mode; a difference drops the line and countsbothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rules 1 and 3; AZ-P12; signals.md). - A deleted message appears only as a tombstone without content and keeps its
thread_seq(03 "Principles" item 7 andmessages; 04 "Thread sequence"). - Rights at the time of delivery: once a revocation has committed, the export sends no further line (07 8.2 item 2; 04 8.1).
- No JSON
nullin any line or response (06 "Basic rules"; 03 "Principles" item 13; ADR 0053). - The use case writes the allow entry with its details; the
authzlayer writes denials, aggregated peraudit.deny_aggregate_window;detailshold only codes, IDs and times (07 4.2; 05 2.4; 19 SEC-AUD-5; R-AZ-04; AZ-P13). rl.exportcounts per principal withratelimit.rl.export.per_minuteandratelimit.rl.export.burst; an exhausted bucket gives429 rate_limitedwithRetry-After, checked beforeauthorize(05 3; 06 9).- No transaction stays open while lines go to the client, and every statement runs under
database.statement_timeout(02 8.5 and 14.2,database.idle_in_tx_timeout); the response runs underhttp.transfer_timeout(02 14.2, "upload and download"). - The response carries
X-Content-Type-Options: nosniff,Cache-Control: no-store,Cross-Origin-Resource-Policy: same-origin,Correlation-Id,RateLimitandRateLimit-Policy(19 SEC-HDR-1 and SEC-HDR-2; 06 "Basic rules", Caching; openapiexportChannel). - No message content, display names or query strings in logs, spans or metrics (19 SEC-LOG-3, SEC-LOG-4).
Scope¶
All paths under services/server/ unless noted. Follow the file and type naming that M1-56 and M1-57 established inside internal/messaging; the names below are the required public names. ids.ID is the ID type of internal/kernel/ids. This ticket creates no migration and no Makefile target.
Create:
-
internal/messaging/export.go(port in the root package, 02 4.3 names itExport), each symbol with a doc comment:// Export is the port of the channel export (02 "4.3 Ports, events and sources per module"). type Export interface { // Channel validates the range, checks export.channel, records the audit entry and // returns the stream. On any error nothing has been sent to the client. Channel(ctx context.Context, q ChannelExportQuery) (ExportStream, error) } // ChannelExportQuery names the channel and the half-open range [From, To) on messages.created_at. type ChannelExportQuery struct { ChannelID ids.ID From, To time.Time } // ExportStream yields the export in batches in export order. Next returns io.EOF after the // last batch and an error wrapping ErrExportAborted when a check during the stream fails. type ExportStream interface { Next(ctx context.Context) ([]MessageView, error) } // ErrExportAborted ends a stream whose re-check failed (Design note 7). var ErrExportAborted = errors.New("messaging: export aborted")MessageViewis the DTO of M1-56 and M1-57; do not add a second one. -
internal/messaging/domain/export.go:ValidateExportRange(from, to time.Time) error(Design note 3c) and the keyset cursorExportCursor{ThreadID ids.ID; ThreadSeq int64}with the pure step logic over thread pages and message pages (Design note 5). internal/messaging/app/export.go: the use caseExportChannelimplementingExport, and the stream.internal/messaging/adapters/postgres/queries/export.sql(sqlc, regeneratedsqlcgen/):ListExportThreadIDsandListExportThreadMessages(Design note 5).internal/messaging/adapters/httpapi/export.go: the strict-server methodExportChanneland the streaming response type (Design note 8).- The tests named in "Tests (write them first)".
Change:
api/operations.yaml: moveexportChannelfromlatertoenabled(format of M1-17); regenerate with the generator thatmake codegen-checkverifies.internal/wire: build theExportuse case with theAuthorizer, the audit recorder, the unit of work and the repository, hand it to the messaging HTTP handler; update the golden files that count routes (AZ-P1).- Deadline class (Design note 9): the route metadata generator of
api/routes(the binary-response flag of M3-05 onroutes.Operation) and, if needed,StageRouteininternal/platform/httpserver, so thatexportChannelruns underhttp.transfer_timeout; regenerate. internal/platform/httpserver, only if missing: the response writer of M1-18 exposesUnwrap() http.ResponseWriter, so thathttp.ResponseControllercan flush a streamed body (a test proves the flush reaches the client before the handler returns).internal/kernel/authorize, only if Design note 7a needs it: a documented function that starts a fresh rights-version memo for one batch while keeping the check log of M1-18.internal/authz/apptests: the decision table forexport.channel(Tests). No change to the requirement table; the entry exists since M1-28.internal/messaging/adapters/postgres/predicate_test.go(M1-57): the two new statements carry the predicate and need no allowlist entry. If M1-70 has replaced this test with the repository-wide lint, that lint covers them.- If the G8 response validator of M1-71 cannot validate an
application/x-ndjsonbody line by line against its schema, extend it so it does (this also coversexportAuditEvents) and list the change in the report. - The canary scenario of M1-69: add an export of the canary message (SEC-T10; the canary text appears in the export body for the authorized reader and nowhere in logs, spans, metrics or audit details).
docs/api/openapi.yaml: extend thedescriptionofexportChannelwith the rulings of Design notes 3c, 4, 5, 7 and 8 (English, one short paragraph); no schema, parameter or response change.docs/spec/02-architecture.md"4.6 Actions per module": correct the bullet onexport.channel(Design note 12).internal/messaging/README.md(keep it in English and keep its section structure: status, portExport, operation, queries, stream and abort rules),services/server/CHANGELOG.md(entry underUnreleased),docs/DECISIONS.mdsection 10 rowsD-M3-11-1toD-M3-11-8(Design note 13).
Out of scope¶
- The export in the clients: the desktop channel menu is M4-33; the server side here is complete for every client.
- Privacy requests and their export packages (
createGdprRequest,downloadGdprExport,kernel/personaldata, files withpurpose = 'gdpr_export'): M7-06. - The audit log export
exportAuditEvents: M1-50. - The answer
503 maintenanceforexport.channel(effectwrite, 05 2.7): M7-08. - Content that the shared representation does not emit yet: attachments (
Message.attachments, M3-07), reactions and pins (M1-59), components (M2). File bytes never go into the export; files are downloaded throughdownloadFile(M3-05). - Direct messages: the table
dm_participantsand DM channels come with V3-01; this ticket proves the DM rule ofexport.channelonly at theauthzlevel. - Delegating the capability
exportthrough grants (grant.create, AZ-M38): M1-48. - The repository-wide SQL predicate lint and the timing part of AZ-P4: M1-70; the Schemathesis run G7: M1-71; the canary scenario itself: M1-69 (extended here).
- A performance target: neither 02 "17. Performance targets" nor the M3 exit criteria name one for the export.
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-11-channel-export; one writer per worktree. - Conventional Commits with the module as scope (for example
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(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.
- No new Go module dependency: lines are encoded with
encoding/jsonand written withnet/httpof the standard library. - No migration and no new index: the queries use the indexes 03 defines on
messages(Design note 5). - No real third-party service in tests; the export calls none.
- 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). Where the merged code of an M1 or M2 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¶
-
Start check.
make checkis green on the fresh worktree, elseVerdict: BLOCKED base branch red. M1-57 and M3-05 are merged. The rate-limit stage of M1-19 (internal/platform/ratelimitwith the classrl.exportand the keysratelimit.rl.export.per_minuteandratelimit.rl.export.burst) is on the base branch: M1-19 is in this ticket's dependency closure and the plan row requiresrl.export; without it stop withVerdict: BLOCKED dependency M1-19 missing. -
Operation.
operationId Route Action Resource given to authorizeClass exportChannelGET /api/v1/channels/{channel_id}/exportexport.channelchannel rl.exportThe export writes no event, changes no rights version and no business table (05: Permissions
no). GET has noIdempotency-Key; every successful call is its own audited export, and after401 step_up_requiredthe client repeats the same request (06 3.3). -
Order of checks. Each step runs only if the previous one passed; nothing reaches the client before step g. a. Rate limit (chain, M1-19):
429 rate_limitedwithRetry-After. b. Parameters (generated validation, M1-18): a missingfromortogives400 validation_failedwith pointer#query/fromor#query/toand coderequired; a value that is not an RFC 3339 time gives codeinvalid_format. c. Range rule, without any lookup, so it cannot leak:frommust be strictly beforeto, else400 validation_failedwith oneerrors[]entry, pointer#query/to, codeinvalid. Ruling for docs/DECISIONS.md: openapi states no rule; an empty or reversed range is a client error. There is no upper bound on the length of the range, because the spec names no key for one;http.transfer_timeoutbounds one response, and clients split long ranges. d.authorize(actor, export.channel, Resource{Kind: channel, ID: channel_id, ChannelID: channel_id})in request mode.authzdecides in the order of 07 4.2: an inactive principal, session, device or token gives401; no read right or an unknown channel givesnot_found, and both take the same code path and return the same error value (02 9.2); read withoutexportgivesforbiddenwith reasoncapability_missing(agents and apps always, because their capabilities are capped atupload); no session of a human givesforbiddenwith reasonstep_up_not_possible; no step-up or one older thanauth.step_up_max_agegivesstep_up_required. Denials are recorded by theauthzlayer and aggregated (R-AZ-04); the use case writes no denial. e.Authorizer.Scope(actor, message.read)gives the visible channel list for the SQL predicate (07 7.4 rule 1). f. The allow entry (Design note 6), committed. g. Response200withContent-Type: application/x-ndjson, then the lines (Design note 8). -
Lines. One line per message: the REST representation
Messageproduced by the single representation function of M1-56 that M1-57 uses for history, encoded withencoding/jsonand ended by exactly one\n. Ruling for docs/DECISIONS.md: the fullcontent_md(never cut atmessage.list_content_max_bytes,content_truncatedabsent), because an export is the complete record;my_reactionsabsent, because it is the caller's state, not channel content, and optional inMessage; tombstones included with the representation of M1-57 Design note 6, because they keepthread_seqand show no content (04 "Thread sequence").attachments,reactions,pinnedandcomponentsare whatever the shared representation emits on the base branch; when M3-07, M1-59 and M2 fill them, the export follows without a change here.json.Marshalescapes control characters, so no line contains a raw newline; a test proves it. -
Selection and order. The export contains the messages with
channel_idequal to the channel andcreated_at >= from AND created_at < to. Order: threads ascending bythread_id, the messages of a thread ascending bythread_seq. Ruling for docs/DECISIONS.md: openapi fixes only "thread_seqorder per thread"; thread ids are UUIDv7 and therefore ordered by creation (03 "Principles" items 1 and 2: order never comes from timestamps), and the pair(thread_id, thread_seq)is a total, resumable keyset. Two statements inqueries/export.sql, both withm.channel_id = @channel_id AND m.channel_id = ANY(@visible_channel_ids::uuid[])and the range:ListExportThreadIDs: distinctthread_idgreater than@after_thread_id, ascending,LIMIT @limit; served by the index(channel_id, created_at DESC).ListExportThreadMessages: the rows of one thread withthread_seq > @after_seq, ascending,LIMIT @limit; served by the unique index(thread_id, thread_seq).
@limitisapi.page_size_maxfor both (asexportAuditEventsin M1-50). A batch is one page ofListExportThreadMessages.ExportCursorwalks thread pages and message pages so that every message in the selection is emitted exactly once and in order, also when messages are added while the export runs. -
Audit. The use case records exactly one allow entry through
auditing.Recorder.Recordinside its owntxn.UnitOfWork.Doand commits it before the response starts: actionexport.channel, decisionallow, resource kindchannel, resource id the channel,Attrsfromandtoasauditing.Time(19 SEC-AUD-4 form), nothing else: no counts, names or content. Actor, session, device, IP pseudonym andcorrelation_idcome from the context (M1-24). If the unit of work fails, the request ends with the problem of the error kind (500 internal_error, or503 unavailablewithRetry-Afterwhen the database is unavailable) and no line is sent. Ruling for docs/DECISIONS.md: no export without its audit entry; the entry names the range and not the result size, because the size is known only at the end and the chain only appends. -
Batches and rights during the stream. Before each batch: a. Start a fresh rights-version memo for the batch (derive the batch context with
authorize.WithRequestScope, or with the function that the Scope list allows ininternal/kernel/authorizeif M1-28 keeps the memo in the check log of M1-18), so the batch uses the rights version current at that moment (07 8.2 item 2; 04 8.1: the rights at the time of delivery). b.authorize(actor, channel.read, channel)in filter mode (authorize.Filter(ctx): no denial audit, 07 4.2). Any outcome but allow ends the stream withErrExportAborted: revoked grant or membership, removed owner, disabled principal, revoked session or device. c. Read the page in a short read-only unit of work and build the representations; that unit of work ends before the first line of the batch is written (02 8.5;database.idle_in_tx_timeout). There is no snapshot across batches: each line shows the state when its batch was read. d.authorize(actor, message.read, message)per row in filter mode, with the descriptor M1-57 uses. A denied row is dropped, logged at error level with only the action and the resource kind, and counted inbothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3, alertBothyAuthzError). Because b and d run on the same memo, a revocation never shows up as a filter mismatch.Ruling for docs/DECISIONS.md: the capability
exportand the step-up are checked once, when the export starts (05 2.4: the action is the start of the export); the stream then re-checks read access only, so a step-up that ages pastauth.step_up_max_agedoes not cut a running export, but a lost read right does. -
Streaming response. The strict-server method returns a response type of the adapter that implements the visitor method of the generated
ExportChannelResponseObjectinterface (with oapi-codegen v2.8.0:VisitExportChannelResponse(w http.ResponseWriter) error). It sets onlyContent-Type: application/x-ndjson, writes200, and then per batch writes the lines and flushes throughhttp.ResponseController.Correlation-Id,RateLimit,RateLimit-Policy,Cache-Control: no-store,Cross-Origin-Resource-Policy: same-originandX-Content-Type-Options: nosniffcome from the chain (M1-18, M1-19) and are not overwritten. The proof check of M1-18 passes because step 3d recordedexport.channelbefore the first write. After the first byte no problem document can follow: onErrExportAborted, a read error, the deadline or a shutdown (02 15.3), the method logs only the cause code and the route pattern and callspanic(http.ErrAbortHandler); the recover stage of M1-18 passes it on (TestRecover_ErrAbortHandlerRepanics), sonet/httpends the response without its final chunk (HTTP/1.1) or resets the stream (HTTP/2). A client therefore never takes a cut export for a complete one. A write error (client gone) ends the stream quietly. A range without messages gives200with an empty body. Ruling for docs/DECISIONS.md. If M3-05 put a streaming helper intointernal/platform/httpserver, reuse it;messagingnever importsinternal/filesadapters (02 4.4). -
Deadline. 02 14.2 gives uploads and downloads
http.transfer_timeout. M1-18 applies it to request bodies of kind multipart and octet-stream; M3-05 adds a flag onroutes.Operationfor operations whose2xxbody isformat: binary, andStageRouteapplieshttp.transfer_timeoutto them (TestOperations_BinaryResponseFlag,TestStageRoute_BinaryResponseUsesTransferTimeout). The200ofexportChannelisapplication/x-ndjsonwith schemaMessage, which that rule does not catch. Extend the generator so the same flag (keep its name; rename nothing) is also set for operations whose2xxresponse media type isapplication/x-ndjson, and update its doc comment to "streamed response". This also givesexportAuditEventsthe transfer deadline, which is right for the same reason; list it in the report. Without this a long export would end afterhttp.request_timeout. Ruling for docs/DECISIONS.md. -
Rate limit.
rl.exportcounts per principal (05 3) and is shared withcreateGdprRequestanddownloadGdprExport; denied requests count too, because the limiter runs beforeauthorize(06 9). -
What already holds and is only proven here, not built: archived channels stay exportable for readers with
export(07 4.5 caps the level atread, capabilities stay); a read-only channel too (05 rule 1.8 blocks onlywriteandtrigger); "view as" givesforbiddenwith reasonview_as_read_onlybecauseexport.channelwrites for M1-28 (classrl.export); the maintenance layer of M7-08 will answer503 maintenancebecause the effect iswrite. -
Spec correction. 02 "4.6 Actions per module" says "
export.channelbelongs tomessaging;filesstores the package." openapiexportChannelstreams the export (06 "Basic rules": "NDJSON lets large exports stream"), and 03files.purposehas no value for a channel export, so nothing is stored. Change the bullet (English) to say thatexport.channelbelongs tomessagingand that the export streams NDJSON without storing a package; export packages infilesbelong only to the privacy export ofprivacy(M7-06). Chapter 02 is not part of the core contract. Ruling for docs/DECISIONS.md. -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason and place:D-M3-11-1range rule (note 3c);D-M3-11-2line content (note 4);D-M3-11-3order (note 5);D-M3-11-4audit before the first byte withfromandto(note 6);D-M3-11-5batches without snapshot and the read re-check per batch, export capability and step-up only at the start (note 7);D-M3-11-6visible abort after the first byte and the empty body (note 8);D-M3-11-7transfer deadline (note 9);D-M3-11-8stream without stored package and the correction of 02 (note 12). -
Threats. The red team of this ticket and of milestone M3 (PLAN 9 "Red team") read this diff for T-AZ-02 (a line of an invisible channel, existence through status, body or timing), T-AZ-03 (a line after a revocation) and T-AZ-04 (an export without audit entry). Each has a test below.
Tests (write them first)¶
Unit:
internal/messaging/domain:TestValidateExportRange:frombeforeto; equal; reversed; a difference of one millisecond.internal/messaging/domain:TestExportCursor, table over fake pages: several threads, a page border inside a thread and at the end of a thread, a thread page that ends exactly at the limit, an empty selection, resuming after a cursor; every(thread_id, thread_seq)appears exactly once and ascending.internal/messaging/adapters/httpapi:TestExportLines_OneMessagePerLine: messages whose content holds a newline, U+2028, U+2029, quotes and backslashes, plus a tombstone: the line count equals the message count, every line decodes intoMessageand validates against the schema, no line contains a raw newline, nonulltoken appears (decoder withUseNumber),content_truncatedandmy_reactionsare absent.internal/messaging/adapters/httpapi:FuzzExportLineEncoding(the seed corpus runs ingo test): anycontent_mdgives exactly one line that decodes back to the same text.internal/messaging/appwith fakes:TestExportStream_FilterMismatchDropsLine: anAuthorizerfake that deniesmessage.readfor one row: the row is missing from the batch, the log record holds only action and resource kind,bothy.authz.errorswithbothy.reason = filter_mismatchrises by one.internal/authz/appwith the source fakes ofinternal/authz/adapters/fake(M1-28):TestExportChannelDecisions, one row each: Alex withreadandexporton Support and a fresh step-up:allow; Alex withtriggeranduploadbut withoutexport:forbiddencapability_missing; Alex withreadandexportwithout step-up:step_up_required; step-up exactlyauth.step_up_max_ageold:allow, one nanosecond older:step_up_required(AZ-M36 pattern); Sam on a Support channel without a grant:not_found; an agent with a grantreadon the channel:forbiddencapability_missing; an installed app with a grant on the channel:forbiddencapability_missing; a personal API token of a person withreadandexport:forbiddenstep_up_not_possible; the owner in a session with a fresh step-up:allow; the owner through a personal API token without own grants on the channel:not_found(R-AZ-14); a DM channel from the fake structure source: participantforbiddencapability_missing, owner who is no participantnot_found(R-AZ-02); archived channel withreadandexport:allow; read-only channel:allow; owner with "view as" Alex:forbiddenview_as_read_only.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain on 127.0.0.1:0 with real authentication and step-up as in M1-35, M1-56 and M1-57, grants through the authz test helpers, a steerable clock; the world of 07 "Examples": space Support with channels Allgemein and Eskalation, space Marketing with channel Kampagnen; in Support/Allgemein the main thread and two further threads with messages before, inside and after the range, one message longer than message.list_content_max_bytes (set small in the test configuration) and one tombstone made with the test helper of M1-57; test data only with example.com addresses):
TestExportChannel_StreamsVisibleHistory: Alex withreadandexporton Support and a fresh step-up:200,Content-Type: application/x-ndjson; exactly the messages of Support/Allgemein withcreated_atin[from, to), threads ascending by id,thread_seqascending within each thread; the long message in full withoutcontent_truncated; the tombstone with emptycontent_mdat itsthread_seq; every line validates againstMessage; no JSONnull.TestExportChannel_OnlyThisChannelAndRange: messages of Support/Eskalation and Marketing/Kampagnen in the same range and messages of Support/Allgemein outside the range never appear;frominclusive andtoexclusive at millisecond precision.TestExportChannel_EmptyRange: a range without messages gives200with an empty body and one audit allow entry.TestExportChannel_WithoutExportIs403(plan row "withoutexport403"): Alex withtriggeranduploadbut withoutexportgets403 forbiddenwithreasoncapability_missingand no body line; no allow entry.TestExportChannel_InvisibleEqualsUnknown(AZ-P4, withratelimit.rl.export.burstraised in the test configuration): Alex on Marketing/Kampagnen and on a random UUIDv7 gets404 not_foundtwice with equal status, body withoutcorrelation_idandinstance, and headers withoutCorrelation-IdandRateLimit. AddexportChannelto the AZ-P4 table of M1-70.TestExportChannel_StepUp(ID-T18 for this action): without step-up401 step_up_required; with a step-up older thanauth.step_up_max_age(steerable clock)401 step_up_required; after a fresh step-up the same request gives200. The case "step-up with a wrong key" belongs to M1-35.TestExportChannel_AgentAndTokens: the token of an agent with a grantreadon Support (agent record of M1-51, token throughtoken.createof M1-44, grant through theauthztest helpers) and a personal API token of Alex both get403 forbidden(reasonscapability_missingandstep_up_not_possible); no line, no allow entry.TestExportChannel_OwnerExportsAnyChannel: the owner in a session with a fresh step-up exports Marketing/Kampagnen without a grant (05 2.3 item 1).TestExportChannel_ArchivedChannel: afterarchiveChannelon Support/Eskalation, Alex withreadandexportstill exports it (07 4.5).TestExportChannel_Audit: one successful export writes exactly one entryexport.channelwith decisionallow,resource_kind = channel, the channel id, actor, session and device of Alex,detailsexactlyfromandtoin the form of 19 SEC-AUD-4, andcorrelation_idequal to the response headerCorrelation-Id; the entry is readable after only the response headers have arrived (committed before the status line).TestExportChannel_DenialsAggregated(AZ-P13): two403attempts of Alex insideaudit.deny_aggregate_windowgive, after the recorder flush of M1-24, exactly one deny entryexport.channelwithdeny_reason = capability_missingand an aggregate count of two.TestExportChannel_AuditFailureSendsNothing: with a recorder that fails, the request ends with500 internal_errorasapplication/problem+json, and no NDJSON line is sent.TestExportChannel_RateLimit(plan row "rl.export"): withratelimit.rl.export.per_minuteandratelimit.rl.export.burstfrom the test configuration, one request more than the burst in quick succession gives429 rate_limitedwithRetry-Afterand theRateLimitheaders of classrl.export; an export by Sam right after still passes (the bucket is per principal).TestExportChannel_RangeValidation: a missingfromortogives400 validation_failedwith pointer#query/fromor#query/toand coderequired; a malformed time gives codeinvalid_format;from = toandfrom > togive400 validation_failedwith pointer#query/toand codeinvalid; the same request on an invisible channel gives the same400(no lookup before the range rule).TestExportChannel_BatchBorders: withapi.page_size_maxsmall in the test configuration and more messages than three batches spread over several threads: every message exactly once, the order of note 5 across batch and thread borders.TestExportStream_RevocationBetweenBatches(AZ-P9 for a stream, through theExportport against PostgreSQL): after the firstNext, revoke Alex's grant through theauthztest helper (a committed revocation that raises the rights version); the nextNextreturns an error wrappingErrExportAbortedand no message.TestExportStream_SessionRevokedBetweenBatches: as above withRevokeSessionof M1-34 instead of the grant.TestExportChannel_AbortIsVisible: a repository wrapper that fails on the second batch: the HTTP/1.1 client reading the body getsio.ErrUnexpectedEOF, and the received bytes are complete lines of the first batch with no problem document after them.TestExportStream_NoTransactionBetweenBatches: between twoNextcallspg_stat_activityshows no connection of the test pool in stateidle in transaction, and a pause longer thandatabase.idle_in_tx_timeout(set small in the test configuration) does not break the nextNext.TestExportChannel_TransferDeadline: the handler context ofexportChannelhas a deadline derived fromhttp.transfer_timeout, not fromhttp.request_timeout(both set to different small values in the test configuration);TestOperations_BinaryResponseFlagof M3-05 now expects the flag also onexportChannelandexportAuditEvents.TestExportChannel_Headers(SEC-T03 for this response kind):X-Content-Type-Options: nosniff,Cache-Control: no-store,Cross-Origin-Resource-Policy: same-origin,Content-Type: application/x-ndjson,Correlation-Id,RateLimit,RateLimit-Policy; a flushed first batch arrives before the handler returns.TestExportChannel_NoContentInLogs(SEC-LOG-3, SEC-LOG-4): with a captured logger, nocontent_md, display name or query string appears in any log record of the integration tests above, and the auditdetailshold onlyfromandto.TestQueriesCarryVisibleChannelPredicate(M1-57, AZ-P12): passes withListExportThreadIDsandListExportThreadMessagesand without a new allowlist entry.TestAZP1_RoutesMatchEnableList(M1-17, AZ-P1): countsexportChannelwith actionexport.channeland classrl.export.
Contract: every response of the integration tests above passes the G8 response validator of M1-71 (06 G8); the Schemathesis run G7 of M1-71 covers exportChannel inside make check.
Run FuzzExportLineEncoding once locally with go test -run=^$ -fuzz=FuzzExportLineEncoding -fuzztime=<duration of your choice> and report duration and result; commit any crashing input as a seed after the fix.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/messaging/... ./internal/authz/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./api/...make codegen-check,make arch,make api-lint,make api-checks,make register-check,make docsmake check
Acceptance criteria¶
-
exportChannelis enabled and registered withexport.channelandrl.export; AZ-P1 passes. - Without the capability
exportthe answer is403 forbiddenwithreasoncapability_missing; without read right it is404 not_found, equal to an unknown channel (AZ-P4); without a fresh step-up401 step_up_required; agent, app and personal tokens are refused (ID-T18). - The export contains only visible content: only messages of this channel in
[from, to), every query with the visible-channel predicate (AZ-P12), every line checked withmessage.read, tombstones without content, no line after a committed revocation (AZ-P9 for a stream). - Lines are complete
Messageobjects, one per line, threads ascending by id andthread_seqascending, full content, no JSONnull. - Every successful export writes exactly one audit allow entry with
fromandtobefore the first byte; an audit failure sends nothing; denials are audited and aggregated (AZ-P13). -
rl.exportthrottles per principal with429 rate_limitedandRetry-After. - An error after the first byte aborts the response visibly; no transaction is open while lines are written; the route runs under
http.transfer_timeout. - The
exportChanneldescription indocs/api/openapi.yaml, the bullet in 02 "4.6 Actions per module", themessagingREADME, the CHANGELOG and the rowsD-M3-11-1toD-M3-11-8are updated. -
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.
- README of
messagingupdated (portExport, operation, queries, the stream, re-check and abort rules of notes 7 and 8); READMEs ofplatform/httpserverandkernel/authorizeonly where this ticket changed them (deadline class, flush, fresh memo). - Rulings of note 13 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. - 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, spec gaps found, and every difference between this brief and the merged code (Boundaries).
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.