M1-63: Catch-up over REST and snapshot¶
Status: ready | Depends on: M1-61 | Milestone: M1
Goal¶
GET /api/v1/events delivers every durable event after a cursor that the caller may see at delivery time, with authorize per event and a cursor that means 'last checked', or 410 resync_required with a reason. GET /api/v1/sync/snapshot delivers structure, counters and versions without thread contents. Both are built as the sync ports CatchUp and Snapshot, which the gateway reuses for ready, resume and streaming catch-up (M1-64, M1-65), so REST and WebSocket follow exactly the same rules (04 'REST variant').
Read first¶
docs/spec/04-sync-protocol.md: 'Envelope', 'Global, commit-ordered sequence', 'Catch-up' with 'Cursor', 'Flow', 'resync_required', 'REST variant', 'Sync epoch'; 'Lifecycle' step 4 (content of the snapshot); 'Visibility on permission changes'; 'Delivery guarantee'; sequence diagram 'Reconnect with catch-up'; decisions R-SY-03, R-SY-04, R-SY-16, R-SY-18.docs/spec/03-data-model.md:event_log,sequence_counters,instance.sync_epoch, 'Sync epoch and restore',read_states,threads(last_message_seq,unread_floor_seq),drafts,user_settings, 'Principles' point 12, R-DM-26.docs/spec/05-actions.md:sync.snapshot.read,events.catch_up('Personal data, notifications, sync'); 'Scopes for apps' (events:subscribe); 'Durable event types and receive action'.docs/spec/07-permissions.md: 'Permission state (snapshot)', 'Validity of the cache', 'Query pattern', 'Delivery filter', AZ-P5 in 'Required tests'.docs/spec/02-architecture.md: 'Rules' of the module list (point 1: only the owning module reads its tables), 'Ports, events and sources per module' (sync:CatchUp,Snapshot), 'Allowed dependencies', 'Cross-cutting ports' (Authorizer.Scope), 'Log tail per api process', 'Permission cache', 'Performance targets' (perf.catchup_page_p99, measured by M1-72).docs/spec/06-api.md: 'Errors' (resync_required), 'Pagination' points 3 and 5, 'Other points' K19.docs/api/openapi.yaml:listEvents,getSnapshot; schemasEventPage,Event,EventEnvelope,Snapshot,SpaceView,ChannelView,SidebarState,UnreadThread,RunSummary,AgentPresence,AgentRuntimeState,UserSetting,DraftRef,PrincipalRef,EvaluatedFlag,InstanceInfo,Problem, responseGone.- ADRs 0009, 0013, 0017, 0052, 0053, 0059.
docs/PLAN.md: 'Way of working per ticket', M1 rows M1-16, M1-28, M1-47, M1-51 to M1-53, M1-61, M1-64, M1-65, M1-70, M1-72; P-12.- Code:
internal/sync(appender, tail, envelope encoder of M1-16, personal data of M1-61),internal/instance(Versions, sync epoch,Flags),internal/authz(Scope),internal/chat(space and channel views oflistSpacesandlistSpaceChannels),internal/agents(listing behindlistAgents),internal/identity(Directory).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- The cursor is the highest
seqchecked for the principal, not the highest delivered; invisible events advance it (04 'Cursor', R-SY-03). - Filter: the SQL predicate of 04 'Flow' point 1 for performance and
authorize(principal, receive_action, resource)per event as the truth; permissions at delivery time apply (04 'Flow' point 2, 'Basic rule'). 410 resync_requiredwith the reasonsepoch_changed,retention,too_far_behind,cursor_ahead(04 'resync_required', 'Sync epoch').- Events with a personal audience go only to that principal (05 'Durable event types and receive action', 07 'Delivery filter').
- The snapshot holds visible spaces and channels with versions, the sidebar state, unread and mention counters, running runs, agent presence, versions of settings and drafts and the evaluated feature flags, and no thread contents (04 'Lifecycle' step 4).
- Counters and snapshot never consider a channel without read permission (AZ-P5, snapshot part).
- No JSON
null(ADR 0053).
Scope¶
All paths under services/server/ unless noted.
Create:
internal/sync/domain/catchup.go: resync decisionfunc ResyncReason(after int64, epoch *uuid, head, minRetained int64, current uuid, maxEvents int64) Reasonand the next-cursor rule.internal/sync/app/catchup.goandinternal/sync/app/snapshot.go.- Port additions in
internal/sync:CatchUp.Check(ctx, CheckInput{After int64; Epoch *uuid}) (CheckResult{HeadSeq, MinRetainedSeq int64; SyncEpoch uuid; Reason ResyncReason}, error)whereResyncReasonis empty or one ofepoch_changed,cursor_ahead,retention,too_far_behind.CatchUp.Page(ctx, PageInput{After, Head int64; Limit int}) (PageResult{Events []EnvelopeJSON; NextCursor int64}, error)for the actor in the context;EnvelopeJSONis the wire form of the M1-16 encoder.Snapshot.Build(ctx) (Snapshot, error)for the actor in the context; the DTO mirrors theSnapshotschema.
internal/sync/adapters/postgres/queries/catchup.sql: the page query,min(seq)ofevent_log.internal/sync/adapters/httpapi/sync.go: strict-server methods forlistEventsandgetSnapshot.- Outgoing port
RunSummarySourceininternal/sync/appand its M1 bindinginternal/sync/adapters/noruns(see note 9).
Change:
- The specification is in place:
docs/api/openapi.yamlhas the optional query parameterepochonlistEventsand the required propertyfeaturesonSnapshot(with example), and 02 'Allowed dependencies' letssyncuseidentity. Change them only where the implementation proves them wrong. Regenerate code (make codegen-check). architecture/modules.yaml: addidentitytodepends_onofsyncas in 02; regenerate.go-arch-lint.yml;make register-checkgreen.- Port additions in
internal/chat, additive and only if absent:Threads.Counters(ctx, channelIDs []uuid) ([]ThreadCounter{ThreadID, ChannelID uuid; LastMessageSeq, UnreadFloorSeq int64}, error)returning only threads withlast_message_seq > unread_floor_seq, query withchannel_id = ANY($1). - If
kernel/authorizehas no constructor for a resource descriptor from an event row, addauthorize.ResourceFromEvent(kind string, id, spaceID, channelID, threadID uuid) Resourcewith tests ininternal/authz; the gateway hub (M1-65) uses the same constructor. api/operations.yaml: enablelistEventsandgetSnapshot.internal/wire: handlers and thenorunsbinding.- ADR 0059: extend its "Confirmation" (note 12).
- READMEs of
sync,chat,authz(if changed);services/server/CHANGELOG.md;docs/DECISIONS.md.
Out of scope¶
- Streaming catch-up over the WebSocket with
cursorframes, buffering of live events andcatchup.complete(M1-65); this ticket providesCheckandPagefor it. - Agent consumer cursors,
acked_seqand redelivery (M2-07); delivery-only fieldsinvocation_tokenandredelivered(M2). - Running runs from the
runsmodule (M2-05) and agent presence from heartbeats (M2-20). bothy restore finalize, which writes a new epoch (M7).- Metrics
bothy.gateway.resyncand others (M1-68); the load test forperf.catchup_page_p99(M1-72); the full AZ-P4 and AZ-P5 suites over all routes (M1-70).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-63-sync-catch-up; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(sync): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except the CHANGELOG, README, ADR and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11).
Design notes¶
Catch-up
HeadSeqis the current value ofsequence_counters('event_seq'), read without a lock throughinstance.Versions(02 "Ports, events and sources per module"; addEventSeq(ctx) (int64, error)next toPermissionVersionof M1-28). Never throughinstance.Counters: its phase guard (M1-15) rejects calls outside the phase hooks. Reason: after retention the log can be empty while the counter is not, andmax(seq)would then reportcursor_aheadwrongly.MinRetainedSeqismin(seq)ofevent_log, orHeadSeq + 1when the log is empty.SyncEpochisinstance.sync_epochfrom the instance port. Because a commit is visible before its counter lock is released, everyseq <= HeadSeqis visible whenHeadSeqis read (04 'Pattern with a counter row').- Check order (first match wins):
epochgiven and different fromSyncEpochgivesepoch_changed;After > HeadSeqgivescursor_ahead;After < MinRetainedSeq - 1givesretention;HeadSeq - After > sync.catchup_max_eventsgivestoo_far_behind. Withoutepochthere is no epoch check;cursor_aheadstays the second net (R-SY-04). Page: get the visible sets fromAuthorizer.Scope(ctx, actor): visible channel ids, visible space ids (at least one visible channel), fully visible space ids (grant on the space) and the instance-wide flag (Owner or grant on the instance). Run exactly the predicate of 04 'Flow' point 1 withseq > $after AND seq <= $head ORDER BY seq LIMIT $limit. Then per row: ifaudience_principal_idis set, deliver only when it equals the actor; otherwise callauthorize(actor, receive_action, authorize.ResourceFromEvent(...))in filter modeauthorize.Filter(ctx)(M1-28; no denial audit, 07 4.2) and deliver only onallow. Never query content tables per event.NextCursor: if the query returnedlimitrows, theseqof the last row; otherwiseHead. Rows dropped byauthorizeor the audience rule still count as checked.- Envelope: use the M1-16 encoder (no
null,redacted: truewhenredacted_atis set,correlation_id).trace_contextonly when the actor is an agent (04 'Envelope'). No delivery-only fields in M1. listEvents:authorize(actor, events.catch_up, instance)(apps needevents:subscribe, 05 'Scopes for apps'). Parameters:afterrequired and at least 0;epochoptional;limitdefaults tosync.catchup_page_sizeand larger values are capped to it (06 'Pagination' point 3). A resync reason gives410with problem coderesync_requiredandreasonset to the reason. Otherwise200 EventPage {events, next_cursor, head_seq, sync_epoch};next_cursoris present even wheneventsis empty.
Snapshot
getSnapshot:authorize(actor, sync.snapshot.read, instance); a pending device gets403 device_pendingfromauthorize.- Consistency: read
head_seqandsync_epochfirst, then all other parts (READ COMMITTED). Every change withseq <= head_seqis then contained; later changes either are contained too or arrive by catch-up afterhead_seq, and duplicates are harmless (04 'Delivery guarantee'). - Parts and their sources:
head_seq,sync_epoch: note 1.permission_version,is_owner: fromAuthorizer.Scope.principal:PrincipalRefof the actor fromidentity.Directory(new allowed edgesync -> identity).spaces,channels:SpaceViewandChannelViewof the visible, not archived spaces and channels with level and capabilities, through the chat port listing that serveslistSpacesandlistSpaceChannels; DM channels of participation are included.sidebar:Settings.ListSidebarof M1-61.unread_threads:Threads.Countersfor the visible channel ids, combined in Go with the actor'sread_statesof those channels: unread =last_message_seq - GREATEST(last_read_seq, unread_floor_seq)(never below 0), and the unread mentions per thread frommessaging.Mentions.CountUnread(M1-60) with the same read positions (computed, never stored, 03 R-DM-32); a thread is listed when unread is above 0 or its mention count is above 0; each listed thread is checked withauthorize(actor, thread.read, thread)in filter modeauthorize.Filter(ctx)and dropped on deny (07 'Query pattern' rule 3). No SQL join across module tables (02 'Rules' point 1); the join in 07 'Query pattern' illustrates the result, not the place of the query.active_runs: fromRunSummarySource. In M1internal/wirebindsinternal/sync/adapters/noruns, which returns an empty list, because the tablerunsexists only from M2-05; its doc comment says so and names M2-05 as the ticket that binds therunsport.agents: agents the actor may read (agent.read) from the agents port listing behindlistAgents, each withstate: unknownand withoutreported_billing_mode, because no heartbeat exists before M2-20 (AgentRuntimeStatedocumentsunknown).settings: all stored rows of the person (M1-61).drafts:DraftRefof the person's drafts in channels the actor can still read (rule of M1-61).features: the flags evaluated for the actor frominstance.Flags(M1-47).- For agents and apps
sidebar,unread_threads,settingsanddraftsare empty arrays.
- No message text, no draft text, no thread title beyond what
ChannelViewandSpaceViewalready carry; the snapshot carries no thread contents (04). - Rulings for
docs/DECISIONS.md: head from the counter (note 1); check order (note 2);active_runsempty and agentsunknownuntil M2-05 and M2-20 (note 9); unread computed through ports (note 9);trace_contextonly for agents (note 5). - ADR 0059 (already in the repository): add
TestListEventsResync(caseepoch_changed) to its "Confirmation" without changing the decision, thenmake adr-index. - Configuration keys used:
sync.catchup_page_size,sync.catchup_max_events,sync.event_retention(through M1-16's partition maintenance in tests).
Tests (write them first)¶
Unit, internal/sync/domain and internal/sync/app with fakes:
TestResyncReasonOrder: table covering each reason, combinations (epoch wrong and cursor ahead givesepoch_changed), an empty log,AfterexactlyMinRetainedSeq - 1.TestNextCursor: page full, page not full, all rows dropped byauthorize.TestPersonalEventsOnlyToAudience.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain; Alex reads Support, Sam reads Support and Marketing, Robin reads nothing):
TestListEventsSkipsInvisible: events of Support and Marketing interleaved; Alex gets only Support events; paging with a smalllimitreacheshead_seq; the cursor passes over Marketing ranges.TestListEventsCursorIsLastChecked: a full page ends at theseqof its last row.TestListEventsPersonalAudience: Sam'sread_state.updatedin Support is not delivered to Alex; Sam receives it.TestListEventsResync: casesretention(partition removed through M1-16's maintenance with a controlled clock),too_far_behind(test configuration with a smallsync.catchup_max_events),cursor_ahead,epoch_changed; each gives410with the rightreason.TestListEventsNewChannelInVisibleSpace: a channel created in a space where Alex has a space grant after Alex's permission snapshot was cached: its events are delivered.TestListEventsAfterRevoke: after Alex's grant on Support is revoked, no Support event is delivered to Alex, also none before the revoke that Alex had not fetched yet (permissions at delivery time).TestListEventsRedacted: a deleted message's event comes withredacted: trueand the redacted payload.TestListEventsLimitCapped:limitabovesync.catchup_page_sizereturns at most that many events.TestListEventsAppNeedsScope: an app withoutevents:subscribegets the same response as without read permission on the instance resource perauthorize.TestSnapshotContents: a world with spaces, channels, threads, read states, mentions, drafts and settings: every part as in note 9; responses validate againstSnapshot,featurespresent.TestSnapshotHeadReadFirst: with a concurrent writer, every change withseq <= head_seqis contained in the snapshot.TestAZP5SnapshotNoForeignChannel: property test over random worlds (reuse the world generator of AZ-P8 frominternal/authztests if it is exported; otherwise build worlds through the public ports): no entry ofspaces,channels,unread_threads,draftsorsidebarand no counter refers to a channel without read permission. Run a fixed number of worlds with a logged seed inmake test.TestSnapshotAgentHasEmptyPersonalParts.TestSnapshotHasNoContent: nocontent_mdmember anywhere in the response.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/sync/... ./internal/chat/... ./internal/authz/... ./internal/kernel/... ./internal/wire/...make codegen-check,make arch,make api-lint,make api-checks,make register-check,make docsmake check
Acceptance criteria¶
-
listEventsandgetSnapshotare enabled and registered; AZ-P1 passes. - The cursor is 'last checked' and passes over invisible events;
next_cursoris present on every page. -
410 resync_requiredwithretention,too_far_behind,cursor_aheadandepoch_changed, in the order of note 2. - Personal events reach only their audience; all other events pass
authorizewith their receive action at delivery time. - The snapshot contains every part of note 9 and validates against the schema, including
features. - AZ-P5 (snapshot part) passes; no response contains thread contents.
- Generated code matches openapi.yaml (
epochonlistEvents,featuresonSnapshot). -
sync -> identityis inmodules.yamland 02;make register-checkandmake archare green. - ADR 0059 "Confirmation" names
TestListEventsResync(caseepoch_changed); its decision text is unchanged (git diffof the ADR touches only "Confirmation"). -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
sync(portsCatchUpandSnapshot, dependencies, thenorunsbinding),chatand, if changed,authz. - Rulings of note 11 recorded in
docs/DECISIONS.md(one row each with reason); ADR 0059 "Confirmation" extended. services/server/CHANGELOG.mdentry under 'Unreleased'.make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Merge locally into the base branch only if review, red team and
make checkare green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the page latency, measured only by M1-72).
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.