M1-61: Read state, drafts and personal settings¶
Status: ready | Depends on: M1-60 | Milestone: M1
Goal¶
The sync module owns the personal sync tables read_states, drafts and user_settings and serves the twelve REST operations that read and write them. Every change writes one durable personal event (audience = self), so all devices of a person converge in server commit order: the read position only moves forward unless it is reset with If-Match, drafts follow last write wins with the overwrote_newer hint, settings follow last write wins per key. Sending a message with clear_draft: true removes the draft in the same transaction, and mentions raise the unread mention counter of the mentioned people. This is the cross-device sync of 01 'Offline, sync, devices' and the personal data that the snapshot (M1-63) and the gateway (M1-64) deliver.
Read first¶
docs/spec/04-sync-protocol.md: 'Cross-device synchronization' (rules per data kind), 'Envelope', 'Delivery guarantee', 'Idempotent writes', 'Editing and deleting'.docs/spec/03-data-model.md: tablesread_states,drafts,user_settings,threads(last_message_seq,unread_floor_seq),mentions,messages,user_space_states; 'Principles' points 1, 10, 11, 12, 13; 'Transaction rules' with 'Lock order'.docs/spec/05-actions.md: section 'Personal data, notifications, sync' (actionsread_state.update,read_state.mark_unread,draft.read,draft.write,settings.read,settings.update); 'Column "Permission"' (self); 'Binding rules' rules 1, 2 and 8; rowsread_state.updated,draft.updated,draft.deleted,settings.updatedin 'Durable event types and receive action'; R-AZ-21.docs/spec/01-product.md: 'Personal settings' (normative keys, values, defaults).docs/spec/13-push-notifications.md: 'Personal switches' (normativenotify.*keys).docs/spec/02-architecture.md: 'Ports, events and sources per module' (syncportsReadStates,Drafts,Settings; subscriptionmessaging.MessagePosted), 'Allowed dependencies', 'Layers within a module', 'Transactions and Unit of Work', 'In-Tx events', 'Idempotency in the Unit of Work', 'Request path', '404 instead of 403'.docs/spec/06-api.md: 'Basic rules' (nonull), 'Errors', 'Idempotency' (rows for PUT and DELETE), 'Concurrency' points 1, 2 and 4, endpoint table 'Preferences'.docs/spec/07-permissions.md: 'Theauthorizefunction', 'Visibility', 'Query pattern' (rule 3: SQL filter plusauthorizeper element).docs/spec/12-files.md: 'Attaching and agent invocation' point 4.docs/spec/20-privacy.md: test DP-T10.docs/api/openapi.yaml: operationslistMyReadStates,putReadState,markThreadUnread,putThreadDraft,deleteThreadDraft,putChannelDraft,deleteChannelDraft,listMyDrafts,listMySettings,putMySetting,deleteMySetting,getMySidebar; schemasReadState,ReadStatePut,ReadStateList,Draft,DraftPut,DraftPutResult,DraftList,UserSetting,UserSettingPut,UserSettingList,SidebarStateList,ReadStatePayload,DraftPayload,DraftDeletedPayload,SettingsPayload,MessageCreate(clear_draft),ThreadCreate.- ADRs 0009, 0013, 0015, 0017, 0021 and, if present, 0052 and 0053.
docs/PLAN.md: 'Way of working per ticket', the M1 rows M1-56, M1-60, M1-62 and M1-63;docs/quality/gates.md;CONTRIBUTING.md.- Code from earlier tickets under
services/server/:internal/sync(appender, idempotency store),internal/kernel/*,internal/chat(threads, sidebar),internal/messaging(MessagePosted,Mentions.CountUnreadof M1-60),internal/authz,api/operations.yaml,architecture/modules.yaml,internal/wire.
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 read position is monotonic:
last_read_seq = GREATEST(old, new); moving it back is the separate operationmarkThreadUnreadwithIf-Match(04 'Cross-device synchronization', R-SY-13). - Drafts: last write wins in server order, every write raises
version; abase_versionolder than the current version answersoverwrote_newer: true; an empty draft is deleted (03drafts, 04 same section, R-SY-12). - Settings: last write wins per key; only the keys and values of 01 'Personal settings' and 13 'Personal switches'; unknown keys and values outside the list are rejected (03
user_settings). - Every change writes a durable personal event with
audience = self:read_state.updated,draft.updated,draft.deleted,settings.updated(04, 05). Payloads carryupdated_by_device_idof the writing device, so a device recognises its own echo (04 'Cross-device synchronization', last sentence). - Sending with
clear_draft: truedeletes the draft in the same transaction as the message (04, 12 'Attaching and agent invocation' point 4). - Unread mentions are never stored (03
read_states, R-DM-32):ReadState.mention_countin responses and inread_state.updatedis computed withmessaging.Mentions.CountUnread(M1-60) at the time of the answer; a new mention writes no personal event (04 'Cross-device synchronization', row Read state).syncsubscribesmessaging.MessagePostedonly to clear drafts (02 4.3). - Every operation runs through
authorizewith its catalogue action; no read permission gives404, never403(B7, 05 rules 1 and 2). Nobody but the owner can read a read state (20, DP-T10). - No JSON
nullin responses or events (06 'Basic rules', 04 'Envelope').
Scope¶
Create, all paths under services/server/:
- Migration
migrations/<next>_sync_personal_data.sql(goose; next sequential version after the newest existing file, same naming pattern; never edit an existing migration): tablesread_states,draftsanduser_settingsexactly as in 03 (columns, types, defaults, checks, foreign keys with theirON DELETE, the partial unique indexesdrafts (user_id, thread_id) WHERE thread_id IS NOT NULLanddrafts (user_id, channel_id) WHERE thread_id IS NULL, the indexread_states (user_id, channel_id)). internal/sync/domain/readstate.go(advance, mark unread, range checks),internal/sync/domain/draft.go(emptiness, validation, theoverwrote_newerrule),internal/sync/domain/settings.go(the key registry:type SettingSpec struct { Key string; Kind SettingKind; Values []string },func KnownSetting(key string) bool,func ValidateSetting(key string, value json.RawMessage) error).internal/sync/app/readstates.go,drafts.go,settings.go,sidebar.goandsubscribers.go(withOnMessagePosted).- Port additions in the root package
internal/sync(own DTOs, no domain types; actor and device come fromkernel/actorin the context):ReadStates:Advance(ctx, AdvanceReadState{ThreadID, LastReadSeq}) (ReadState, error),MarkUnread(ctx, MarkUnread{ThreadID, LastReadSeq, IfMatchVersion}) (ReadState, error),ListForChannel(ctx, channelID) ([]ReadState, error).Drafts:PutThreadDraft(ctx, PutDraft) (DraftPutResult, error),PutChannelDraft(ctx, PutDraft) (DraftPutResult, error),DeleteThreadDraft(ctx, threadID) error,DeleteChannelDraft(ctx, channelID) error,List(ctx) ([]Draft, error).Settings:List(ctx) ([]UserSetting, error),Put(ctx, key string, value json.RawMessage) (UserSetting, error),Delete(ctx, key string) error,ListSidebar(ctx) ([]SidebarState, error)(the sidebar is read undersettings.read, R-AZ-21).
internal/sync/adapters/postgres/queries/read_states.sql,drafts.sql,user_settings.sqlwith the sqlc output and the repositories that implement the outgoing ports ofapp.internal/sync/adapters/httpapi/personal.go: the strict-server methods for the twelve operations named in 'Read first'.
Change:
api/operations.yaml: move the twelve operations fromlaterinto the enabled list and rerun the route generator.internal/wire: register the handlers and the in-transaction subscriptionmessaging.MessagePosted -> sync.OnMessagePosted; update the golden file of subscriptions (mechanism of M1-13) in the same commit.internal/messagingport:MessagePostedof M1-56 already carriesMessageID,ThreadID,ChannelID,AuthorID,AuthorDeviceID,ClearDraftandCreatedThread; use them. Unread mention counts come fromMentions.CountUnreadof M1-60 (a dependency of this ticket); add no second counting method.internal/chatport, additive only, unless M1-52 or M1-55 already offer them:Threads.InfoForShare(ctx, threadID) (ThreadInfo, error)returningChannelID,SpaceID,ChannelKind,LastMessageSeqandUnreadFloorSequnderFOR SHAREon the thread row, andStructure.ListSidebarStates(ctx, userID) ([]SidebarState, error).- READMEs
internal/sync/README.md,internal/messaging/README.md,internal/chat/README.md;services/server/CHANGELOG.md;docs/DECISIONS.md.
Out of scope¶
putMySidebarSpaceand the tableuser_space_states(M1-52); this ticket only reads them through the chat port.- Moving threads and the subscription
chat.ThreadMovedthat updatesread_states.channel_idanddrafts.channel_id(M1-62). GET /api/v1/events, the snapshot and the per-recipient filter for personal events (M1-63); gateway delivery (M1-64, M1-65).- The mention list and templates (M1-60); notification preferences and quiet hours (
notify, M3). - Checking that
drafts.file_idsbelong to the person and are attachable: saving checks form and count only (04 'Cross-device synchronization'); attachability is checked when the message is sent (12 'Attaching and agent invocation'), and file cleanup protects only files of the draft's author (12 'Retention', M3-08). - Export and deletion of these tables for data subject requests (
kernel/personaldata.Participant, M7, P-14).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-61-sync-personal-data; 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 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¶
Read state
putReadState: firstauthorize(actor, read_state.update, thread)(unknown or invisible thread gives404). Then0 <= last_read_seq <= threads.last_message_seq, otherwise400 validation_failedwitherrors: [{pointer: /last_read_seq, code: out_of_range}]. StoreGREATEST(old, new). If the stored value does not change, answer200with the current state, write no event and keepversion. A missing row is the implicit statelast_read_seq = 0,version = 0(itsmention_countis computed like any other); advancing a missing row to0writes nothing.- On a real change:
version = version + 1,updated_by_device_id= device of the actor,updated_at = now(),channel_idfromThreads.InfoForShare, oneread_state.updatedwhose payload and the response carrymention_countcomputed byMentions.CountUnreadfor this thread with the newlast_read_seq(never stored). markThreadUnread:If-Matchis required (428 precondition_requiredwhen missing; use the M1-18 helpers). The ETag of a read state is itsversionin the format of 06 'Concurrency' point 1; a missing row has version0. Mismatch gives412 version_conflictwith the currentReadStateincurrent. The body value must be at most the currentlast_read_seq, otherwise400 validation_failed(out_of_range; advancing isputReadState). An equal value answers200without event. Otherwise store exactly that value, raiseversion, computemention_countas in note 2, write the event and answer with theETagheader. The middleware of M1-20 handlesIdempotency-Key.- Unread counts are never stored: unread is
threads.last_message_seq - GREATEST(last_read_seq, threads.unread_floor_seq)(03read_states), computed by readers (M1-63). OnMessagePostedruns in the message transaction and only clears drafts (ClearDraft, note 12). It writes no read state and no event for mentions: clients count new mentions frommessage.created(mention_ids) between two read-state answers. The server does not move the author's own read position; clients advance it over REST (04 'Cross-device synchronization', row Read state).listMyReadStates(settings.readon the own user): rows of the given channel only if the actor can read the channel (visible set fromAuthorizer.Scope), each row additionally checked withauthorize(actor, thread.read, thread)in filter modeauthorize.Filter(ctx)(M1-28; no denial audit, 07 4.2) and dropped on deny (07 'Query pattern' rule 3). An invisible and an unknown channel give the same200withitems: []. Every query on the three tables has the predicateuser_id = <actor>.
Drafts
putThreadDraftanddeleteThreadDraft:authorize(actor, draft.write, thread).putChannelDraftanddeleteChannelDraft:authorize(actor, draft.write, channel); the channel must havekind = forum(a text channel drafts on its main thread), otherwise400 validation_failedwith adetail;reply_to_message_idis not allowed on a channel draft (400, pointer/reply_to_message_id).draft.writeneedsself and read(05), so drafts can be saved and deleted in read-only and archived channels; sending checksmessage.create.- Validation:
content_mdat mostmessage.max_content_bytesbytes (400 validation_failed, pointer/content_md, codetoo_long; if M1-56 answers an over-long message with another status or code, use exactly that and say so in the report);file_idswithout duplicates and at mostmessage.max_attachmentsentries, each a UUIDv7 (400, pointer/file_ids/<index>);reply_to_message_idmust name a message of the same thread, checked through the messaging port, otherwise400, pointer/reply_to_message_id, codenot_in_thread, identical for unknown and foreign ids. - Empty draft (04 'Cross-device synchronization', row Drafts):
content_mdwithout any non-whitespace character and nofile_ids. A PUT of an empty draft deletes the row like DELETE, writesdraft.deletedif a row existed, and answers200withdraftas the final state (content_mdempty,file_idsempty,version= last version plus one, or1if none existed) andoverwrote_newerper note 10. overwrote_newer = row exists AND (base_version absent OR base_version < row.version). Create: new UUIDv7id,version = 1; update:version + 1;channel_idof a thread draft fromThreads.InfoForShare. Concurrent first writes must not fail on the unique index: useINSERT ... ON CONFLICT ... DO UPDATEon the partial unique index and computeoverwrote_newerfrom the version seen under the row lock.- Deleting: delete if present and write
draft.deletedwithDraftDeletedPayload; answer204also when there was no draft (06 'Idempotency'). clear_draftinOnMessagePosted: ifClearDraft, then whenCreatedThreadis true and the channel is a forum, delete the author's channel draft (thread_id IS NULL) of that channel; otherwise delete the author's draft ofThreadID. Writedraft.deletedwhen a row was deleted. A rollback of the message transaction keeps the draft.listMyDrafts(draft.read): own drafts whose channel is still readable (visible set plusauthorize(actor, thread.read, thread)for thread drafts orauthorize(actor, channel.read, channel)for channel drafts, in filter modeauthorize.Filter(ctx)). Drafts of lost channels stay stored (classpersonal_sync) but are not listed (04 'Cross-device synchronization').
Settings
- The registry holds exactly the keys of 01 'Personal settings' and 13 'Personal switches'. Enumerations (
appearance.theme,appearance.density,appearance.reduce_motion,notify.default_level,notify.preview_mode) accept exactly the listed strings. Booleans (composer.enter_sends,privacy.crash_reports,privacy.client_metrics,notify.input_needed_breaks_quiet_hours,notify.admin_enabled) accept JSONtrueorfalse.layout.column_widthsaccepts a JSON object whose member names match^[a-z][a-z0-9_]*$and whose values are non-negative integers (logical pixels), as 01 states. JSONnullis never a valid value. putMySetting: unknown key404 not_found(the path names no resource); invalid value400 validation_failed, pointer/value. A value equal to the stored one answers200without event and without version change. Otherwise insert withversion = 1or update withversion + 1, setupdated_by_device_id, writesettings.updatedwithSettingsPayload {setting}.deleteMySetting: unknown key404; known but unset204; set: delete the row, writesettings.updatedwithSettingsPayload {setting: {key, value: last stored value, version: last version + 1}, deleted: true}, answer204. Defaults are neither stored nor returned; clients apply the defaults of 01 and 13 (04 'Cross-device synchronization', row Settings). A later PUT of the key starts again atversion = 1; clients drop the local entry ondeleted: true, so the new entry applies (ruling: restart at 1).listMySettings: all stored rows of the person.getMySidebar(settings.read): the person'suser_space_statesfromStructure.ListSidebarStates, only for spaces that are not archived and that the actor may read (space.read), asSidebarStateList.
Events
- All durable,
audience_principal_id= the person, appended throughkernel/eventlog.Appenderin the same unit of work, receive action and event types from the generated catalogue constants (M1-02, M1-25):
| Type | resource |
scope |
Payload |
|---|---|---|---|
read_state.updated |
{kind: thread, id: thread_id} without version |
space, channel, thread | ReadStatePayload |
draft.updated |
{kind: draft, id, version} |
space, channel, thread (thread absent for channel drafts) | DraftPayload |
draft.deleted |
{kind: draft, id, version: last + 1} |
as draft.updated |
DraftDeletedPayload |
settings.updated |
{kind: user, id: user_id} without version |
empty | SettingsPayload |
resource.versionis absent onread_state.updatedandsettings.updated(04 'Delivery guarantee' point 3): their resource kindsthreadanduserhave their own or no object version. Clients compare the version inside the payload.- If an encoded
DraftPayloadexceedssync.event_payload_max_bytes, cutcontent_mdat a UTF-8 boundary so that the payload fits and settruncated: true, with the same mechanism M1-56 uses formessage.created; clients reload throughlistMyDrafts.
Permissions, concurrency, limits
- Agents and apps cannot use these operations (they act on a person's own data).
authorizemust answer403 forbiddenwith reasonsubject_kind_not_allowedfor an agent or app token. If the requirement step of M1-28 does not yet do this forselfactions, add the case ininternal/authzwith a unit test; permission logic lives only inauthz. Threads.InfoForSharetakesFOR SHAREon the thread row, so a concurrent move (M1-62 updates that row) either commits first and the copiedchannel_idis the new channel, or waits. Severalread_statesrows are written in ascendinguser_id.- Rate limits and idempotency keys come from the HTTP chain (M1-19, M1-20); do not reimplement them.
- Configuration keys used (all listed in
docs/operations/configuration.md):message.max_content_bytes,message.max_attachments,sync.event_payload_max_bytes. If the schema of M1-07 lacks one, add it from its chapter table and runmake config-doc-check.
Ruling for docs/DECISIONS.md: note 16 (a deleted setting restarts at version = 1). Notes 5, 9, 14, 20 and the defaults of note 16 are in 01 and 04.
Tests (write them first)¶
Unit, internal/sync/domain:
TestAdvanceKeepsMaximum,TestAdvanceRejectsSeqAboveLastMessage,TestMarkUnreadOnlyMovesBack.TestOverwroteNewer: table with no row; row and base absent; base equal; base older; base newer.TestDraftEmptiness: whitespace only, files only, reply only.TestDraftValidation: content overmessage.max_content_bytes, too many and duplicatefile_ids, reply on a channel draft.TestSettingValues: per key valid and invalid values, includingnull, numbers for booleans and nested objects inlayout.column_widths.TestSettingRegistryMatchesSpec: reads the tables indocs/spec/01-product.md'Personal settings' anddocs/spec/13-push-notifications.md'Personal switches' (find the repository root from the test file path), extracts keys and enumerated values and fails on any difference to the registry.
Unit, internal/sync/app, with fakes for the chat and messaging ports, kernel/authorize and the repositories:
TestOnMessagePostedWritesNoReadState,TestOnMessagePostedClearDraftThreadAndForumPost.
Integration (PostgreSQL 18 via internal/platform/pg/pgtest, real modules through internal/wire, HTTP through the real chain, people with active devices and DPoP proofs from the identity test helpers of M1-33 and M1-34):
TestPutReadStateMonotonic: advance to 10, then to 5: the value stays 10, the second call writes no event and keepsversion.TestReadStateEventIsPersonal: theevent_logrow hastype = read_state.updated,audience_principal_id= the person, the scope ids of the thread, a payload valid againstReadStatePayload,updated_by_device_id= the calling device, no JSONnull.TestMarkThreadUnreadPreconditions: missingIf-Matchgives428; stale gives412withcurrent; success returnsETagand writes the event.TestMarkThreadUnreadTwoDevices: two devices send the sameIf-Matchin parallel: exactly one200and one412.TestMentionCount: Sam mentions Alex in two messages:listMyReadStatesshowsmention_count = 2and noread_state.updatedwas written for the mentions; Alex advances past the first message: the answer and its event say1; past both:0; deleting a mentioning message lowers the count without any write toread_states.TestDraftsTwoDevicesServerOrder: device A puts without base: version 1,overwrote_newer = false; device B puts with base 1: version 2, false; A puts with base 1: version 3,overwrote_newer = true.TestDraftsConcurrentFirstWrite: two parallel first PUTs without base: both200, one row, versions 1 and 2, exactly one response withoverwrote_newer = true.TestClearDraftSameTransaction: a draft exists;createMessagewithclear_draft: trueremoves it;draft.deletedandmessage.createdcarry the samecorrelation_id; a unit of work that runsOnMessagePostedand then rolls back keeps the draft.TestClearDraftForumPost:createThreadin a forum withfirst_message.clear_draft: truedeletes the channel draft.TestEmptyDraftPutDeletes,TestDraftReadOnlyChannel(save and delete succeed in a read-only and in an archived channel; sending from there gives403 channel_read_only),TestDraftEventTruncated(content nearmessage.max_content_bytes: payload withinsync.event_payload_max_bytes,truncated = true).TestSettingsPutValidation(404unknown key,400invalid value),TestSettingsPutSameValueNoEvent,TestSettingsDeleteWritesDeletedEvent,TestSettingsDeleteUnset204.TestDPT10ForeignReadStateNotRetrievable: Sam sees none of Alex's read states through any of the twelve operations; Alex's events carryaudience_principal_id= Alex (the per-recipient filter is tested in M1-63).TestListMyReadStatesInvisibleChannel: a foreign and an unknown channel give identical status, body and headers.TestListMyDraftsHidesLostChannels: after a grant is revoked the draft is no longer listed and is still stored.TestGetMySidebarVisibleOnly.TestPersonalOperationsRejectAgentToken:403on each of the twelve operations for an agent token.- If the response validator of G8 exists, all integration tests run with it.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/sync/... ./internal/messaging/... ./internal/chat/... ./internal/authz/... ./internal/wire/...make codegen-check,make arch,make migrations-lint,make api-lint,make config-doc-check,make register-checkmake check
Acceptance criteria¶
- The migration creates
read_states,draftsanduser_settingsexactly as in 03;make migrations-lintis green and a secondbothy migrate upchanges nothing. - The twelve operations are in the enabled list of
api/operations.yamland registered; the route count test (AZ-P1) passes. -
putReadStatenever lowerslast_read_seq;markThreadUnreadwithoutIf-Matchgives428, with a stale one412withcurrent. - Draft writes from two devices end in server order with
overwrote_newerexactly as in note 10, also for concurrent first writes. -
clear_draft: trueremoves the draft in the message transaction; a rollback keeps it. - Unknown setting keys give
404, invalid values400; the registry test against 01 and 13 passes. - Every real change writes exactly one personal event of the right type; a mention writes none; a no-op writes none; payloads validate against their schemas and carry
updated_by_device_idwhen a device wrote. - DP-T10 passes; invisible and unknown ids give identical responses.
- Agent and app tokens are rejected with
403on all twelve operations. - The golden file of in-transaction subscriptions contains
messaging.MessagePosted -> sync. -
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,messagingandchatupdated (status, ports, tables, actions, events, subscriptions). - Ruling of note 16 recorded in
docs/DECISIONS.mdwith its 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. - 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.
- 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>.