M4-08: Domain package¶
Status: ready | Depends on: M4-01 | Milestone: M4
Goal¶
After this ticket packages/domain holds the client's entities, value objects and pure rules in plain Dart without Flutter: typed identifiers and a UUIDv7 generator for the outbox IDs, the entities of the local schema mirror of 15 6.1 with only the columns a client sees, closed enums with an unknown member for values a newer server adds, the sync rules of chapter 04 that every other package must apply the same way (version rule, cursor rule, monotonic read state, gaps in thread_seq, unread and mention counting from message events), the display rules of 01 (message grouping, forum sort with the Done section, cost labels), and the app configuration table: every value of place App in docs/operations/configuration.md with its default, held equal to that reference by a test. Why: local_store, sync_engine, auth, design_system mappings and the apps all decide with these rules (15 1 principle 5, 15 3.1); written once and tested without a device, they cannot drift between packages.
Read first¶
docs/PLAN.md: section 3; section 10 rows M4-08, M4-12 to M4-16 (they build on this package).docs/spec/15-client-architecture.md: 1 (principles 2, 5), 3, 3.1 (rule 1), 5 "Data flow" (version rule and thereaction.*andpin.*exception), 6.1 "Schema mirror" (local tables and their notes), 7.3, 7.5, 19, 22 (the client values).docs/spec/04-sync-protocol.md: 2.2 "Envelope", 4 "Thread sequence", 5.1 (client_msg_idis a UUIDv7), 7.1 "Cursor", 9 "Delivery guarantee" (items 2 and 3 with the list of types withoutresource.version), 13 "Offline behavior of the client", 14 "Cross-device synchronization" (read state monotonic, client counts unread and mentions frommessage.*withmention_ids, drafts withoverwrote_newer).docs/spec/03-data-model.md: the tables of 15 6.1 (spaces,user_space_states,channels,forum_tags,threads,thread_tags,messages,message_attachments,files,reactions,mentions,message_components,runs,principals,agents,commands,read_states,drafts,user_settings,notification_preferences,quiet_hours,instance_settings) and their enum columns; 6.5 (redaction).docs/spec/01-product.md: 3.3 (long replies), 6.1 (Done section), 6.7 ("Costs"), 11 (setting keys and defaults), 14 (configuration values);docs/spec/13-push-notifications.md3.3 (notify.*keys and defaults, only to list them).docs/spec/05-actions.mdsection 6 (event types and which carryresource.version),docs/spec/actions.yaml(events:entries withkind,resource,receive_actionandresource_version).docs/operations/configuration.mdsection 4 (every row with placeApp, the rows of the keys D-W-115 names; the column layout);docs/api/openapi.yamlschemaInstanceLimits(limits the client reads from the server, never fromAppConfig).docs/api/openapi.yamlanddocs/api/asyncapi.yaml(enum schemas such asTriggerState,RunState,ChannelKind,BillingMode).docs/DECISIONS.md: D-W-100, D-W-114, D-W-115, D-W-116; section 10. Briefs:docs/briefs/M4-01-workspace-arch.md.
This ticket touches no API operation, no table of chapter 03, no action, no event and no configuration key.
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, and the ARB test of M4-06 rejects them in interface texts. 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. Interface texts come frompackages/l10n, never as literals in widgets. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md. This ticket creates the client side of that rule:AppConfig(Design note 6) holds every key of placeAppand the server-place keys of D-W-115 and D-W-116 with the default of the reference, and every package reads values from it by key; code comments name the key, never the number. - Specification first: if an entity needs a field that chapter 03 or the API lacks, stop (Boundaries); this ticket changes only 15 (the sentence that names
AppConfig) and adds no spec content.
Product requirements implemented here:
- Pure Dart: no
package:flutter, nodart:ui, no I/O, no clock and no randomness except through injected functions (15 1 principle 5, 15 3.1 rule 1). - Version rule: a change applies only if
resource.versionis greater than the local version; an event withoutresource.versionapplies inseqorder; events withseqat or below the cursor are discarded (04 9 items 2 and 3). - Read state is monotonic; resetting is a separate operation (04 14, R-SY-13). Unread and mention counters are counted by the client from
message.created,message.updatedandmessage.deletedwithmention_ids; the server delivers the computed values in the snapshot andread_state.updated(04 14). thread_seqis gapless per thread; a gap means missing data (04 4); the loaded ranges detect it (15 6.1loaded_ranges).- Outbox IDs are UUIDv7 created by the client (04 5.1).
- Fields that need
manage(channel prompt, skills) do not exist in the client entities (15 6.1). - Unknown enum values of a newer server decode to
unknownand never throw (06 13 item 3).
Scope¶
Create in packages/domain/lib/src/:
ids.dart(typed IDs),uuid7.dart(Uuid7Generator),seq.dart(GlobalSeq,ThreadSeq,ResourceVersion,PermissionVersion).entities/one file per entity group:space.dart,channel.dart,thread.dart,message.dart(withTriggerState,MessageKind,MessageOrigin),file.dart,reaction.dart,mention.dart,component.dart,run.dart(summary andRunState,StepKind,StepStatus),principal.dart(Principal,AgentCard,BillingMode,AgentPresence),command.dart,personal.dart(ReadState,Draft,UserSetting,NotificationPreference,QuietHours),instance.dart(InstanceInfowith branding and evaluated feature flags).rules/:version_rule.dart,cursor_rule.dart,read_state_rule.dart,loaded_ranges.dart,unread_counter.dart,message_grouping.dart,forum_order.dart,cost_label.dart,event_catalog.dart(Design notes 3 to 5).config/app_config.dartand the generatedconfig/app_config_keys.g.dart(Design note 6).lib/domain.dartexports.- Tests under
test/(Tests) andtools/configdoc/tests/test_app_config.pyor a Dart test reading the reference (Design note 6).
Change:
packages/domain/pubspec.yaml(no runtime dependency exceptmetaandcollectionif needed),README.md.docs/spec/15-client-architecture.md22 (one sentence: the app reads its values throughAppConfiginpackages/domain, held equal to the configuration reference).docs/DECISIONS.mdsection 10: rowsD-M4-08-<n>; rootCHANGELOG.mdunder[Unreleased].
Out of scope¶
- Persistence (M4-12), mapping from
api_clientmodels (M4-12 and M4-14 map into these entities), the sync state machine (M4-13), run documents and their patches (M4-16), notification level resolution (M4-33 uses 13's rules), permission logic of any kind (15 1 principle 1). - Overriding
AppConfigvalues from the server or a build configuration: the apps decide later; this ticket provides the table and the override hook only.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-08-domain; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(client): ...,feat(sync-engine): ...,build(gates): ...,docs(spec): ...) 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. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - 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.1with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only viamktemp -d(DartDirectory.systemTemp.createTemp, pytesttmp_path), removed at the end. Stop only processes you started, by PID. - Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of
make checkdo themselves. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH(and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows
branding.product_namefrom the instance, never a literal. - No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
- If the core contract (chapters 00, 03, 04, 05) or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider 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¶
-
Identifiers and UUIDv7. One extension type or small final class per ID kind (
SpaceId,ChannelId,ThreadId,MessageId,RunId,PrincipalId,FileId,DeviceId,HostId,TerminalSessionId,ComponentId,CommandId) wrapping the canonical lowercase string;parseaccepts the 36-character form in either case and rejects anything else with aFormatExceptionthat does not echo the input beyond its length.Uuid7Generator({required int Function() nowMillis, required int Function(int bits) random})produces RFC 9562 version 7 UUIDs with a monotonic counter within the same millisecond (method 1 of RFC 9562 6.2: a 12-bitrand_acounter seeded randomly, incremented within a millisecond, and on overflow the timestamp advanced by one), so IDs sort in creation order on one device. -
Entities. Immutable classes with
==,hashCode,copyWithand aversionwhere the server table has one; fields named as in 03 inlowerCamelCase; optional fields as nullable Dart fields (the wire has nonull, 04 2.2; absence maps tonullin Dart).MessagehasthreadSeq,trigger_stateasTriggerState,deletedAt,editedAt,contentTruncated,mentionIds; it has noclientPayloadSha256(15 6.1).Channelhas noprompt, no skills and no project binding details beyond whatchannel.readreturns. Enums: one per enum column or schema with exactly the values of 03 and the API plusunknown;fromCode(String)never throws. A test reads the enum values fromdocs/api/openapi.yaml(walk up to the repository root) and fails when a value of the spec has no member. -
Sync rules.
ApplyDecision decideApply({required ResourceVersion? local, required ResourceVersion? incoming, required bool typeCarriesVersion}):applywhen the type carries a version andincoming > localorlocalis absent;ignoreStalewhenincoming <= local;applyInSeqOrderwhen the type carries none.typeCarriesVersioncomes fromEventCatalog, generated fromdocs/spec/actions.yaml(events:withkindandresource_version) intoevent_catalog.g.dartwith a gate that compares (the generator intools/catalog/if M1-02's tool offers an output hook, otherwise a small Python script intools/catalog/with tests; record which). An event type unknown to the catalogue isunknownand skipped by the caller.bool isDuplicate(GlobalSeq seq, GlobalSeq? cursor): true whencursor != null && seq <= cursor.ReadState mergeReadState(ReadState local, ReadState incoming):lastReadSeqis the maximum unlessincomingis an explicit reset with a newer version (the separate operation of 04 14); counters from the incoming computed values.LoadedRanges(immutable):add(ThreadSeq from, ThreadSeq to)merges overlapping and adjacent ranges;contains(seq);gapsUpTo(ThreadSeq lastMessageSeq)lists missing ranges;fits(ThreadSeq seq)tells whether a new message continues a loaded range or opens a gap (15 7.3 item 4).UnreadCounter:apply(MessageEvent e, {required PrincipalId me, required ThreadSeq lastReadSeq})updatesunreadandmentionsper thread formessage.created(not own, above the read state; mention whenmentionIdscontainsme),message.updated(mention added or removed above the read state),message.deleted(decrement if it was counted);resetFrom(ReadState)takes the server's computed values. Imported content never counts (unread_floor_seq, 04 14).
-
Display rules.
bool isContinuation(Message previous, Message next, Duration window)(same author, same kind class,next.createdAt - previous.createdAt < window, neither is a system message); window fromui.message_group_window.ForumOrder.sort(List<Thread>, ForumSort sort)returns open threads bylastActivityAtor creation descending and a separate Done list (closed threads) in the same order (01 6.1, components.md 6).CostLabel costLabel({required BillingMode mode, required bool isEstimate}):estimatefor organization whilecost_is_estimate,finalafter reconciliation,informationOnlyfor personal (01 6.7). -
Event catalogue.
EventCatalog.lookup(String type) -> EventInfo?withdurable,carriesResourceVersion,resourceKind,personal(audience self). Generated, never edited by hand (Design note 3). -
App configuration. Ruling for docs/DECISIONS.md:
AppConfigis the client's single source of the values of placeAppand of the server-place keys that steer the client's own behaviour without an API that delivers them (D-W-115:sync.reconnect_backoff_base,sync.reconnect_backoff_cap,sync.typing_min_interval,sync.typing_ttl,sync.max_focused_threads,client.cache_max_age,client.outbox_retry_baseandclient.outbox_retry_cap; D-W-116:auth.device_pending_ttl; the generator keeps this list as a constant with the reason, and a test fails when a listed key is missing from the reference or changes its place). Limits thatInstanceLimitsofgetInstancedelivers (file_max_upload_bytes,voice_max_duration_ms, the message limits) are never inAppConfig; the apps read them from the server.app_config_keys.g.dartis generated from the rows ofdocs/operations/configuration.mdsection 4 with placeAppand the listed rows (key, kind derived from the default: duration, int, bytes with unitsKiB,MiB,GiB, ratekbpsandMiB/s, float, px, list);AppConfig.defaults()returns all of them;AppConfig.withOverrides(Map<String, String> raw)parses with the same parsers and rejects unknown keys and malformed values (aFormatExceptionnaming the key, not the value). Typed getters per key (Duration outboxStuckAfter,int quickSwitchMaxResults, ...) named after the key. The generator is a small Python tool next totools/configdoc/(standard library, ruffD, pytest) with a--checkmode wired intomake codegen-check; a value of placeAppthat is added to a chapter table without regeneration fails the gate. Values of placeAppthat the native extensions also read (for exampleclient.push.fetch_timeout, which the Notification Service Extension reads too) are included inAppConfiglike every other value of placeApp; the README notes that native code must read the same reference. -
Rulings for docs/DECISIONS.md (
D-M4-08-<n>): typed IDs and the monotonic UUIDv7 counter (note 1); enums withunknownand the spec test (note 2); the generated event catalogue (note 3); the forum and grouping rules (note 4);AppConfiggenerated from the reference (note 6).
Tests (write them first)¶
packages/domain/test/ (package:test):
uuid7_test.dart: version and variant bits; 1000 IDs in the same millisecond sort strictly increasing; the timestamp advances on counter overflow;parserejects 35 and 37 characters and braces.enums_test.dart: every enum value of the spec schemas has a member;fromCode('rocket')isunknown.version_rule_test.dart: newer applies, equal and older are ignored, absent local applies, a type without version givesapplyInSeqOrder(reaction.added,pin.added,read_state.updated), an unknown type is unknown.cursor_rule_test.dart,read_state_rule_test.dart(never decreases on a normal update; an explicit reset with a newer version lowers it).loaded_ranges_test.dart: merge adjacent and overlapping ranges; gaps;fitsat the edge and with a gap.unread_counter_test.dart: own message not counted; message at or below the read state not counted; mention added by an edit; deleted message decrements; imported message belowunread_floor_seqnot counted;resetFromtakes the server values.message_grouping_test.dart,forum_order_test.dart,cost_label_test.dart.event_catalog_test.dart: the generated catalogue equalsdocs/spec/actions.yaml(every durable type present with itsresource.versionflag).app_config_test.dart:defaults()contains every key of placeAppof the reference and every key of D-W-115 and D-W-116 with the reference default (the test readsdocs/operations/configuration.md);withOverridesrejects an unknown key and a malformed duration;outboxStuckAfterparses2mas two minutes.- Generator tests (pytest) for the
AppConfigand event catalogue generators: deterministic output;--checkfails on a stale file.
Commands that must pass, from the repository root:
cd packages/domain && flutter test --coverage(pure Dart tests through the rootMakefilerules)make codegen-check(the gate that holds the generated files),make arch,make lint,make fmt-check,make coveragemake check
Acceptance criteria¶
-
dart test(throughmake test) is green;domainimports nopackage:flutter(make arch). - The version, cursor, read state, loaded range and unread rules match 04 9 and 04 14 with the tests listed.
- Every enum of the spec has a member and an
unknownfallback. -
AppConfigcontains every key of placeAppand the keys of D-W-115 and D-W-116 with the reference default; a stale generation fails the gate. - The event catalogue is generated from
actions.yamland checked. - Rulings recorded; 15 22 names
AppConfig. -
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 and name the chapter and section of each rule.
packages/domain/README.md(English): entities, rules with their sources,AppConfig, the generators.- Rulings of Design note 7 in
docs/DECISIONS.mdsection 10; 15 22 updated. - Root
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.
- The number of entities, enums, rules and
AppConfigkeys; where the two generators live. - What is not proven (for example a platform test that ran only in CI, or a check done by hand).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.