M1-57: Reading history¶
Status: ready | Depends on: M1-56 | Milestone: M1
Goal¶
After this ticket a principal who may read a thread pages through its history by thread sequence
(newest page, before_seq, after_seq, around_seq) and loads any single message in full. Pages
are keyset pages over thread_seq: stable against new messages, contiguous, and with tombstones at
the positions of deleted messages, so a client recognises a real gap as missing data. Nothing leaks:
an invisible thread or message answers exactly like an unknown one, and every content query carries
the visible-channel predicate. Clients, the offline cache, and later the agent run context and the
search build on this read path.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 7 "M1 Foundation" (row M1-57, "Test strategy", "Red team").docs/briefs/README.md("How a brief runs").docs/spec/00-glossary.md: Tombstone, Thread sequence, Message.docs/spec/02-architecture.md: "Modules of the monolith" (4.1 Rules, 4.3 Ports, 4.4 Allowed dependencies, 4.5 Cross-cutting ports), "Layers within a module", "Code layout", "Request path" (9.1 to 9.3), "Error and timeout strategy" (14.1).docs/spec/03-data-model.md: "Principles" (items 7, 12, 13), tablesmessagesandthreads(sections 4.5 and 4.6), "Transaction rules" 6.2.docs/spec/04-sync-protocol.md: "Envelope" (payload rule 2,truncated), "Thread sequence", "Delivery guarantee", "Ordering".docs/spec/05-actions.md: section 1 rules 1 to 3; "Target of a resource"; "Decision"; table 4.6 (thread.read) and table 4.7 (message.read); section 9 item 4.docs/spec/06-api.md: "Basic rules", "Authorization in the API", "Errors", "Pagination" (items 2, 3, 5), "Concurrency" (item 1), "Endpoints" > "Messages", "Configuration values of this chapter".docs/spec/07-permissions.md: "Theauthorizefunction" (4.1, 4.2, 4.3), "Query pattern" (7.4, rules 1 and 3), "Test matrix" (AZ-M52) and "Required tests" (AZ-P4, AZ-P12).docs/spec/19-security.md: "Logs without secrets and without content" (SEC-LOG-3, SEC-LOG-4).docs/api/openapi.yaml: operationslistThreadMessages,getMessage; schemasMessage,MessagePage,PageLimit,Problem; parametersThreadIdPath,MessageIdPath.- ADRs in
docs/adr/:0008-hexagonal-modules-and-checked-dependency-rules.md,0013-spec-first-openapi-31-and-asyncapi-30-normative.md,0015-error-format-rfc-9457-idempotency-pagination.md,0017-one-authorization-function-404-instead-of-403-mandatory-registration.md; ADR 0053 (no JSONnull) once M1-16 has written it. - Code of earlier tickets:
services/server/internal/messaging(M1-56: tables, theMessagerepresentation,createMessage), thechatportThreads(M1-55),internal/kernel/authorize(Authorizer,Scope; M1-28),internal/platform/httpserver(problem format, ETag helper; M1-18),internal/platform/pg/pgtest(M1-12),services/server/api/operations.yaml(M1-17), and the integration test world of 07 "Examples" used by M1-54 and M1-56. docs/quality/gates.md,CONTRIBUTING.md,docs/quality/red-team-gate.md,services/server/internal/messaging/README.md.- Tables:
messages(read). Actions:thread.read,message.read.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: write the failing test, record it red, then make it green (PLAN section 3 item 3).
- No en dash or em dash characters in anything you write; use a hyphen or rephrase.
- White label: no company, person, host or domain names; examples use Alex, Sam, Robin, the spaces
Support, Marketing, Engineering, Research and
example.com. - Limits and windows only through named configuration keys from
docs/operations/configuration.md; prose and comments name the key, never the number. - History of one thread over
thread_seq: at most one ofbefore_seq,after_seq,around_seq; without any, the newest page; items ascending; tombstones included; the window rules ofaround_seqand the flags as in 06 "Pagination" item 5 (openapilistThreadMessages; 04 "Thread sequence"). - Keyset pages over
thread_seq: a new message never changes an existing page (06 "Pagination" items 2 and 5; ADR 0015). - A deleted message keeps its row and
thread_seqand is returned as a tombstone without content (03 "Principles" item 7; 03messages). - No read permission gives
404 not_found, identical for foreign and unknown IDs in status, body and headers (05 rule 2; 07 4.2; 02 "404 instead of 403"; AZ-M52; AZ-P4). - Every SQL statement on messaging tables carries
channel_id = ANY($visible)(directly or via a join tomessages), and every returned element is checked again withauthorize(07 7.4 rules 1 and 3; preparation for AZ-P12). - Lists cut
content_mdabovemessage.list_content_max_bytesat a UTF-8 character boundary and setcontent_truncated: true;getMessagealways returns the full content and is the reload path for content cut in lists and events (06 "Pagination" item 5, 04 "Envelope" rule 2,Message.content_truncated). Reason: one page stays belowapi.page_size_maxtimes that value. limitdefaults toapi.page_size_default; larger values are capped atapi.page_size_max, not rejected (06 "Pagination" item 3).getMessagereturnsETag: "<version>"(06 "Concurrency" item 1); no JSONnullin any response (06 "Basic rules").- No message content, query strings or display names in logs or telemetry (SEC-LOG-3, SEC-LOG-4).
Scope¶
All paths below services/server/ unless stated. Follow the file and type naming M1-56
established inside internal/messaging; the names below are the required public names.
- Port
internal/messaging(interfaceMessages), new methods with doc comments:History(ctx context.Context, q HistoryQuery) (HistoryPage, error)Get(ctx context.Context, messageID ids.ID) (MessageView, error)HistoryQuery{ThreadID ids.ID; Mode HistoryMode; Anchor int64; Limit int}withHistoryModevaluesHistoryNewest,HistoryBefore,HistoryAfter,HistoryAround;Limit = 0meansapi.page_size_default.HistoryPage{Items []MessageView; HasMoreBefore bool; HasMoreAfter bool}.MessageViewis the message DTO M1-56 introduced; reuse it, do not add a second one.ids.IDstands for the UUID type ofinternal/kernel/ids. The acting principal comes from the context (kernel/actor), as in M1-56. Later callers:runs(run context, M2),search(M3).
internal/messaging/domain: pure functions for request validation and window selection (ValidateHistoryRequest,PickAround,PageFlags), see Design notes 3 and 4.internal/messaging/app: use casesListThreadMessagesandGetMessage.internal/messaging/adapters/postgres/queries/history.sql(sqlc, regeneratedsqlcgen/): one statement per mode (ListThreadNewest,ListThreadBefore,ListThreadAfter,ListThreadAround) andGetVisibleMessage(reuse M1-56's lookup if it already carries the visible predicate).internal/messaging/adapters/httpapi: strict-server methodsListThreadMessagesandGetMessage.internal/messaging/adapters/postgres/predicate_test.go:TestQueriesCarryVisibleChannelPredicate(Design note 7).api/operations.yaml: movelistThreadMessagesandgetMessagefromlaterinto the built list (format from M1-17); regenerate with the generatormake codegen-checkverifies.- Docs:
internal/messaging/README.md(keep its English text and section structure; status, operations, queries),services/server/CHANGELOG.md(entry underUnreleased).
Out of scope¶
- Sending messages, idempotency by
client_msg_id, mention validation, event truncation (M1-56). - Editing, deleting, revisions, redaction (M1-58). This ticket only renders tombstones correctly.
- Reaction summaries,
my_reactions,pinned(M1-59). Render whatever the shared representation function produces on your base. - Mention list, templates (M1-60); read states and unread counters (M1-61); thread move (M1-62); REST catch-up and snapshot (M1-63).
- Attachments (M3) and components (M2): keep the empty arrays the representation already emits.
- The repository-wide SQL predicate lint with exception list (M1-70); the full leak suite (M1-70); load tests (M1-72); the order binding of agents in a run, SR9 (M2).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-57-messaging-history, created from the base branch the launcher names, after checking that the tree is clean and M1-56 is merged. - Conventional Commits with scope
messaging(orapiforoperations.yaml), every commit with DCO sign-off (git commit -s); squash into meaningful commits before the local merge (docs/quality/push-discipline.md). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope list, except the CHANGELOG entry and
docs/DECISIONS.mdentries named here. - Follow the host rules supplied by the launcher (ports, machine rules, SonarQube, deny list).
- Tests bind only
127.0.0.1and port 0; PostgreSQL 18 runs as a throwaway container throughpgteston a random port (P-09, P-10). Without a Docker runtime the test fails; it never skips. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them. - Never read or print secrets; never open
.envfiles. - No network calls except package downloads for pinned tools and pinned Go modules.
- Do not lower a threshold, disable a gate, skip or delete a test.
- If the core contract (chapters 00, 03, 04, 05) lacks something this brief needs, stop with
Verdict: BLOCKED <reason>(P-11) instead of building a deviation.
Design notes¶
-
Operations.
operationId Route Action Resource given to authorizeClass listThreadMessagesGET /api/v1/threads/{thread_id}/messagesthread.readthread rl.readgetMessageGET /api/v1/messages/{message_id}message.readmessage (thread, channel, author) rl.read -
Order of checks for
listThreadMessages(each step only if the previous passed):- Query validation without any lookup (it cannot leak): more than one of
before_seq,after_seq,around_seqgives400 validation_failedwith oneerrors[]entry per given parameter, pointer#query/<name>, codemutually_exclusive.after_seqmust be>= 0,before_seqandaround_seq>= 1, all below 2^53; otherwise400 validation_failed, codeout_of_range. - Load the thread through the
chatport and callauthorize(thread.read, thread). Missing and invisible threads take the same code path and return the same error value (02 9.2). - Get the visible channel IDs for
message.readfromAuthorizer.Scope. - Run the one statement for the mode (note 4); then call
authorize(message.read, message)per item (cached snapshot) in filter modeauthorize.Filter(ctx)(M1-28; no denial audit, 07 4.2). A denied item is dropped, logged at error level with only the action and the resource kind (no IDs, no content), and counted inbothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3, signals.md, alertBothyAuthzError). - Build each item with the single representation function of M1-56.
- Query validation without any lookup (it cannot leak): more than one of
- Window.
L = min(limit or api.page_size_default, api.page_size_max).- Newest: the
Lhighestthread_seq. before_seq = X: theLhighest withthread_seq < X.after_seq = X: theLlowest withthread_seq > X.around_seq = X: fetchB= up toLrows withthread_seq < X(descending) andA= up toLrows withthread_seq >= X(ascending).b0 = min(|B|, (L - 1) / 2)(integer division),a = min(|A|, L - b0),b = min(|B|, L - a). Items are the firstbofBreversed, followed by the firstaofA. A short side gives its share to the other side, so a page holdsmin(L, messages in thread)items.- Items are always ascending by
thread_seqand contiguous: the server never returns a gap inside a page, becausethread_seqhas no gaps (03 6.2) and tombstones keep their number.
- Newest: the
- Flags and snapshot.
has_more_beforeis true iff a message withthread_seqbelow the first item exists;has_more_afteriff one above the last item exists. With no items:before_seq = Xgiveshas_more_before = falseandhas_more_after= existsthread_seq >= X;after_seq = Xgiveshas_more_after = falseandhas_more_before= existsthread_seq <= X; newest and around give bothfalse(empty thread). Items and both flags come from one SQL statement per request (CTEs withLIMIT L + 1per direction andEXISTSfor the opposite side), so all values stem from one snapshot underREAD COMMITTED. The statements use the unique index(thread_id, thread_seq)and containm.thread_id = @thread_id AND m.channel_id = ANY(@visible_channel_ids::uuid[]). - List representation: as
getMessage, butcontent_mdcut atmessage.list_content_max_bytes(UTF-8 boundary) withcontent_truncated: truewhen cut, absent otherwise. - Tombstone representation (one function, also used by M1-58): when
deleted_atis set, theMessagehascontent_md = "",attachments = [],mention_ids = [],reactions = [],my_reactions = [](REST),components = [],pinned = false,deleted_atpresent,content_truncatedabsent; all other fields as stored. Tests create tombstones through a test helper that setscontent_md = '',deleted_at,deleted_byandversion = version + 1directly, because the delete operation arrives with M1-58. getMessage: load with the visible predicate, thenauthorize(message.read, message); missing and invisible return the same error value.200with the REST representation (fullcontent_md, nevercontent_truncated) andETag: "<version>"from the M1-18 helper. Tombstones are200too.TestQueriesCarryVisibleChannelPredicateparses everyqueries/*.sqlfile ofinternal/messaging/adapters/postgresand fails when aSELECT,UPDATEorDELETEstatement that namesmessages,mentions,reactions,pins,message_revisionsormessage_templatescontains neitherchannel_id = ANY(nor an entry in an allowlist map in the test (query name -> reason). Seed the allowlist with M1-56's statements that legitimately lack it (for example the replay lookup by author andclient_msg_id), each with its reason. M1-70 later replaces this test with the repository-wide lint.- No JSON
null: absent optional fields are omitted (encoder of M1-18). No new configuration key. - 404 equality: compare status, body without
correlation_idandinstance, and headers withoutCorrelation-Id.
Tests (write them first)¶
Unit (internal/messaging/domain, table-driven):
TestValidateHistoryRequest: two or three anchors, negative and zero values, values at 2^53, valid single anchors.TestPickAround: middle of a long thread, near the start (fill from above), near the end (fill from below), anchor beyond the last message,L = 1,L = 2, empty thread.TestPageFlags: every mode with full, partial and empty results.
Integration against PostgreSQL 18 (pgtest, real authz sources, world of 07 "Examples"; HTTP
through the real chain on 127.0.0.1:0 with real authentication as in M1-48 and M1-56):
TestListThreadMessages_NewestPage: 30 messages,limit=10gives 21 to 30,has_more_before = true,has_more_after = false.TestListThreadMessages_BeforeAfterAround: each mode against note 3, including both flags.TestListThreadMessages_PagesStableAgainstNewMessages:before_seq=21&limit=10returns 11 to 20; after five new messages the same request returns the identical body.TestListThreadMessages_PagingCoversThreadExactlyOnce: walking backwards from the newest page withbefore_seqyields everythread_seqfrom 1 to N exactly once, each page contiguous.TestListThreadMessages_TombstonesKeepThreadSeq: a tombstone appears at its position with the representation of note 6; the page stays contiguous.TestListThreadMessages_ListContentCut: withmessage.list_content_max_bytesset small in the test configuration, a long message appears cut at a UTF-8 boundary withcontent_truncated: true;getMessagereturns it in full.TestListThreadMessages_LimitDefaultAndCap: with the test configuration setting small values forapi.page_size_defaultandapi.page_size_max, nolimitreturns the default size and a largerlimitis capped.TestListThreadMessages_ParamErrors:400 validation_failedwith the pointers and codes of note 2.TestListThreadMessages_InvisibleThreadIs404: Alex on a Marketing thread and a random UUIDv7 give equal responses (note 9).TestListThreadMessages_OperatorRuleReads: the operator agent of a channel reads its thread without a grant (05 2.3 item 4).TestGetMessage_FullContentWhenEventTruncated: withsync.event_payload_max_bytesset small in the test configuration, a long message'smessage.createdrow is truncated, whilegetMessagereturns the fullcontent_mdwithoutcontent_truncated.TestGetMessage_ETagMatchesVersionandTestGetMessage_Tombstone.TestAZM52_GetMessageForeignEqualsUnknown: Alex requests a Marketing message ID and a random UUIDv7; responses equal per note 9.TestQueriesCarryVisibleChannelPredicate(note 7).- A captured-logger assertion in the integration tests: no
content_md, query string or display name appears in any log record.
Commands that must pass:
cd services/server && go test -race ./internal/messaging/...make codegen-check,make arch,make api-lint,make docsmake check(includesmake sonaragainst the local SonarQube; if it is not reachable, stop withVerdict: BLOCKED, never setSONAR_SKIP).
Acceptance criteria¶
-
listThreadMessagesandgetMessageare registered fromapi/operations.yaml, each runsauthorizewith its catalogue action (the proof check of M1-18 stays green). - Every window and flag case of notes 3 and 4 is covered by a passing test.
- Pages are stable against new messages and contiguous; tombstones keep
thread_seq. - Foreign and unknown thread and message IDs give equal
404responses (AZ-M52). - Every messaging SQL statement carries the visible predicate or an allowlisted reason, and
every listed item passes
authorize(message.read). -
getMessagereturns the full content andETag: "<version>". - No JSON
nulland no content in logs. - README, doc comments on every new public symbol and CHANGELOG entry are present.
-
make checkis green.
Definition of done¶
Code, tests and docs are complete: module README updated, a doc comment on every public symbol,
an ADR if an architecture decision was taken (none expected; record smaller rulings beyond 06 such as the
around_seq window in docs/DECISIONS.md), a CHANGELOG entry under Unreleased. make check is
green. An independent review and the red team (docs/quality/red-team-gate.md) have run over the
diff and their verdicts are recorded in the report. Merge locally into the base branch only if both
are green, then git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red run of each
new test before the code), what is not proven, and follow-ups that could not be done in scope with
the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.