M1-52: Spaces and sidebar¶
Status: ready | Depends on: M1-46, M1-51 | Milestone: M1
Goal¶
After this ticket the module chat owns spaces: the top level of the sidebar with a global order
kept as a fractional index (moving changes exactly one row), a color and an icon from the design
system palette, archiving, and each person's collapsed state and own order, synchronized across
devices through personal events. chat becomes the space part of authz.StructureSource, so
authorize resolves spaces from real data. Channels, threads and operators build on this in
M1-53 to M1-55.
Read first¶
docs/PLAN.md, section 7: row M1-52, "Known contradictions" (none for this ticket), "Red team"; section 3 "Way of working per ticket".docs/briefs/README.md.docs/spec/03-data-model.md: "1. Principles" (1, 5 sort keys, 6, 7, 10, 12, 13), tablesspaces,user_space_states,grants(FKtarget_space_id),agents; "6.3 Lock order".docs/spec/05-actions.md: "2.2 Target of a resource", "2.3 Effective level", "2.6 Scopes for apps" (space.read), "4.5 Spaces, channels, sidebar" rowsspace.read,space.create,space.update,space.reorder,space.archive,sidebar.update; "6" rowsspace.created,space.updated,space.archived,sidebar.updated; R-AZ-06.docs/spec/07-permissions.md: "4.3 Visibility", "7.3 Validity of the cache", "7.4 Query pattern", "8.2 Immediate effect on revocation".docs/spec/04-sync-protocol.md: "2.2 Envelope", "8.1 Basic rule", "8.2 What happens on a change", "14. Cross-device synchronization" (row Sidebar).docs/spec/02-architecture.md: "4.3" (modulechat: portStructure, In-Tx eventSpaceArchived, sourceauthz.StructureSource), "4.4" (chatmay useidentity,authz), "4.5 Cross-cutting ports", "5. Layers within a module", "7.1 Module register as the source", "8.1" and "8.2", "11.3 Permission cache".docs/spec/01-product.md: "3.1 Structure and conversations" (first row), "9. Languages", "14. Configuration values".docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-T07.docs/spec/06-api.md: "6. Pagination" (rule 6), "7. Idempotency", "8. Concurrency", "16. Endpoints" groups "Spaces" and "Preferences".docs/api/openapi.yaml: operationslistSpaces,createSpace,getSpace,updateSpace,moveSpace,archiveSpace,putMySidebarSpace; schemasSpace,SpaceCreate,SpaceUpdate,MovePosition,SpaceView,SpaceViewList,SidebarState,SidebarStatePut,ReasonBody,SpacePayload,SidebarPayload,SpaceCreatedEvent,SpaceUpdatedEvent,SpaceArchivedEvent,SidebarUpdatedEvent.design/build/tokens.resolved.json(category colorsamber,gorse,granite,heather,loch,moss,rowan,spruce) and the generated palette and icon key list of M1-46 with its gate check.- Code already merged:
internal/authz(snapshot,authorize,Scope, phase hook,StructureSourcedeclaration and fakes),internal/identity(display-text validation of M1-26),internal/agents(M1-51),internal/instance(M1-46),internal/kernel/*,internal/platform/pg/pgtest,services/server/api/operations.yaml. docs/quality/gates.md,CONTRIBUTING.md,docs/quality/red-team-gate.md.
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.
Note on rule 3: In this repository docs/api/openapi.yaml is normative and the server interface is
generated from it (ADR 0013); the route test keeps both identical.
- Test first: every behaviour gets a test that fails before the code exists (red), then passes (green). Keep the red output for the report.
- No en dash or em dash characters (U+2013, U+2014) in any file or commit message.
- White label: example spaces Support, Marketing, Engineering, Research; people Alex, Sam, Robin; no company, host or person names.
- Limits and durations only through named keys from
docs/operations/configuration.md; no bare numbers in production code or documentation. - Specification first: the chapter and API changes below land before the code that uses them.
- Product requirements implemented here: spaces collapsible and sortable, global order only by
owners (
space.reorder), own collapse and order per person, synchronized across devices (01 "3.1", 04 "14"); color and icon only as keys of the design system, never hex or emoji (03spaces); archiving as a permission change (05space.archive, "Permissions" =yes); display texts without control or bidi characters (SEC-IN-4).
Scope¶
The specification is in place (planning, spec first): 03 spaces (name limit
space.name_max_chars in 03 9, icon from space-icons), 07 "4.5" and "7.2"/"7.4" (archive rule;
Scope builds lists per request, so new spaces need no new permission version), and in
docs/api/openapi.yaml the descriptions of SpaceView.level, SpaceCreate, SpaceUpdate and
archiveSpace. Change them only where the implementation proves them wrong; run make api-lint,
make api-checks and the M1-17 generation target; make codegen-check shows no diff.
Code (paths under services/server/):
- Migration (next free sequential goose version in
migrations/): tablesspacesanduser_space_statesexactly as 03 (columns, checks, FKs, unique and partial indexes,COLLATE "C"). Ifgrants.target_space_idhas no foreign key yet (M1-27 ran beforespacesexisted), addgrants.target_space_id -> spaces(id) ON DELETE RESTRICThere. internal/chat(port):Structurewith the space and sidebar part:ListSpaces,GetSpace,CreateSpace,UpdateSpace,MoveSpace,ArchiveSpace,PutSidebarSpace,ListSidebarStates(ctx, userID)(needed by M1-61getMySidebar); DTOs; In-Tx eventSpaceArchived{SpaceID ids.ID}.internal/chat/domain: space entity and rules; packageinternal/chat/domain/fracindex(KeyBetween(a, b string) (string, error),Valid(key string) bool).internal/chat/app: use cases per operation.internal/chat/adapters/postgres(sqlc queriesspaces.sql,sidebar.sql, repository),internal/chat/adapters/httpapi(handlers of the seven operations),internal/chat/adapters/authzsource(the space part ofauthz.StructureSource).- Shared helpers (kernel-core, 02 "4.5"): the generated palette and
space-iconslist ininternal/kernel/designkeys(M1-46) and the display-text validatorinternal/kernel/textcheck(M1-26); use them as they are. internal/authz: only if the base lacks it (each with a test): in an archived space every effective level is capped atread(07 "4.5"; M1-53 builds the same cap for channels, share one implementation);Scopefor spaces can express "all spaces" for owners and principals with instance-levelread, so new spaces are listed without a new permission version; an effective level and capabilities query for one target (used forSpaceView), built on the same snapshot asauthorize.internal/wire: register handlers and the space part of the structure source; keep the channel and thread part of the existing placeholder until M1-53 and M1-55.services/server/api/operations.yaml: enable the seven operations.- Configuration: register
space.name_max_chars(positive integer) in the typedchatconfig and the config schema. - Docs:
internal/chat/README.md,services/server/CHANGELOG.mdunder Unreleased, rulings indocs/DECISIONS.md.
Out of scope¶
setSpaceOperatorand the operator rule (M1-54); channels, forum tags and channel scope (M1-53); threads (M1-55);getMySidebarand the snapshot (M1-61, M1-63).- Notification preferences cleanup on archive (
notifysubscribesSpaceArchivedin M3); personal data export and erasure foruser_space_states(M7-06, P-14). - An unarchive operation (not in 05).
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 branch
feat/M1-52-chat-spaces. - Conventional Commits with scope (
feat(chat): ...,docs(spec): ...) and DCO sign-off. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except the CHANGELOG and docs entries named here.
- Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
Design notes¶
Catalog facts (05 "4.5"): space.read = read on the space or on at least one of its channels
(rl.read); space.create owner, no step-up, audit (rl.admin); space.update manage, no
step-up, audit (rl.admin); space.reorder owner, no step-up, audit (rl.admin); space.archive
owner, step-up, audit, "Permissions" = yes (rl.admin); sidebar.update self and read, no audit
(rl.write). Apps need scope messages:read for space.read.
Fractional index (fracindex, compatible with the widely used base-62 scheme with integer part):
- Digits
0-9A-Za-zin ASCII order, so byte order equals key order (COLLATE "C"). - A key is an integer part plus an optional fraction. The integer part starts with a head letter:
atozmean 1 to 26 following digits,AtoZmean 26 to 1 following digits (negative range). The fraction must not end with0. The smallest possible integer (Afollowed by 260) is invalid. KeyBetween(a, b)returns a key strictly betweenaandb; empty means open end. Rules: both empty givesa0;bempty increments the integer part ofa(and falls back to a fraction when the integer cannot grow);aempty decrements the integer part ofblikewise; otherwise the shared prefix plus the digit midpoint.a >= bis an error.- Normative vectors (tests):
("", "") -> "a0",("a0", "") -> "a1",("", "a0") -> "Zz",("a0", "a1") -> "a0V". Property tests: result strictly between, valid, and repeated inserts at the end keep short keys. - Order of lists:
sort_key COLLATE "C", thenid.
Global order:
CreateSpacewithafter_space_idinserts after that space, else at the end;MoveSpacewithMovePosition: exactly one ofafter_idandbefore_id, none moves to the start, both give400 validation_failed, the space itself or an unknown or archived ID gives400 validation_failedwith the pointer of the field.- To keep keys distinct under concurrency, create and move lock all non-archived space rows in
ascending ID (
SELECT id FROM spaces WHERE archived_at IS NULL ORDER BY id FOR UPDATE), then read neighbours and compute the key. Spaces are a small set (06 "6" rule 6), so this is cheap and follows the lock order of 03 "6.3". A move updates exactly one row (sort_key,version + 1).
Validation:
- Name: trimmed, non-empty, at most
space.name_max_charscharacters, no control or bidi characters (SEC-IN-4); unique case-insensitive among non-archived spaces, else409 conflict. color: a category color key from the generated palette list;icon: an icon key from the generated list. Hex values, emoji and unknown keys give400 validation_failed(pointer/coloror/icon).SpaceUpdatecannot cleariconorcolor(nonull, no clear field; its description says so).updateSpacerequiresIf-Match(428missing,412stale withcurrent), bumpsversion.
Visibility and archive:
listSpaces: non-archived spaces whereScopeallows (owners and instance-level readers: all; others: spaces with a space-level grant or a readable channel), predicate in SQL of the formarchived_at IS NULL AND ($all OR id = ANY($visible_spaces)), thenauthorize(space.read)per item (07 "7.4"). Items areSpaceViewwith level and capabilities on the space; a space visible only through a channel reportsreadand no capabilities. No pagination (06 "6" rule 6).getSpace:SpaceViewwithETag; not visible or unknown gives the same404; an archived space stays readable for those who could read it (witharchived_at).- Archive rule (07 "4.5 Archived spaces and channels"): archiving is final; in an archived space
every principal, owners included, has at most
read; writing and triggering actions there end with403 channel_read_onlyandreason: channel_archived; management changes of an archived space (updateSpace,moveSpace, operator) give409 conflict; archiving it again returns200with the current space and writes nothing;listSpacesomits archived spaces. The data stays for retention and export. archiveSpace(ReasonBody, Idempotency-Key): setsarchived_at, bumpsversion, runs with a target-basedRightsChangeof M1-29 (Targets, 02 8.1; phase hook:permission_versionfirst, affected principals are all whose effective level in the space was aboveread,access.changedwith achangedentry per affected target at levelread), publishesSpaceArchived, appendsspace.archived, audit with the reason. The gateway cache appliesspace.archivedin log order (02 "11.3", 04 "8.1").- New spaces are visible to owners and instance-level readers without a new permission version:
the REST path must not depend on a cached list of spaces (see
Scopeabove).
Sidebar (putMySidebarSpace, PUT, no If-Match, last write wins per space, 04 "14"):
- Only humans; agents and apps get
403(subject_kind_not_allowed). Not visible space gives404. - Body:
collapsedrequired;sort_keyoptional and must satisfyfracindex.Valid, else400;use_global_order = trueremoves the personal key;sort_keytogether withuse_global_order = truegives400; neither keeps the stored key. Its length is bounded byhttp.max_json_body_bytes. - Upsert of
user_space_stateswithversion + 1andupdated_by_device_idfrom the caller's device (absent for personal tokens). ResponseSidebarState. - Event
sidebar.updatedwithaudience = selffor the caller, resource{kind: space, id: space_id, version: state version}, payloadSidebarPayload.
Events (all durable, no null, scope {space_id}): space.created (create), space.updated
(update and move), space.archived (archive), payload SpacePayload, resource version = space
version.
Rulings to record in docs/DECISIONS.md: fractional index scheme and lock for create and move
(the other decisions are in the specification).
Tests (write them first)¶
Integration tests use pgtest (PostgreSQL 18 throwaway container on 127.0.0.1, random port)
with real authz, identity, agents and chat. Test names carry the IDs where the specification has
them.
TestFracindexVectorsandTestFracindexProperties(random pairs: strictly between, valid; invalid inputs rejected).TestMoveSpaceChangesExactlyOneRow: compare all rows before and after a move: only the moved row differs (sort_key,version).TestMoveSpacePositions:after_id,before_id, empty body to the start, both fields400, self400, unknown400; result order as expected.TestConcurrentMovesKeepDistinctKeys: parallel creates and moves into the same gap end with distinct keys and a total order.TestUpdateSpaceIfMatch: missing428, stale412withcurrent, fresh200.TestSpaceColorAndIconArePaletteKeys:#3366ff, an emoji and an unknown key give400;mossand a key from the generated icon list succeed.TestSECT07_SpaceNameRejectsControlAndBidi: names with U+202E and U+0007 give400;TestSpaceNameLimitAndUniqueness: overspace.name_max_chars400,supportafterSupport409, a name reused after archive succeeds.TestSpaceReadWithReadOnOneChannelOnly: Sam hasreadon one channel of Support and nothing on the space:getSpace200with levelread, listed inlistSpaces. The channel comes from a test structure source that wraps the real chat source and adds one channel (nochannelstable yet); M1-53 repeats the test with real channels.TestSpaceCreatedVisibleWithoutRightsVersion: Robin has an instance-levelreadgrant and a cached snapshot; the owner creates a space; Robin's nextlistSpacesandgetSpaceshow it whilepermission_versionis unchanged.TestSpaceArchiveIsRightsChange: Alex hastriggeron Support, Samread; archive with step-up:permission_version+ 1,access.changedfor Alex and the owner with achangedentry at levelread(none for Sam, whose level does not change),space.archivedinevent_log,SpaceArchivedpublished, audit with reason; afterwardsgetSpacestill answers for all three,listSpacesomits the space,updateSpacegives409 conflict; archive without step-up401.TestSpaceOperationsRights: create and move owner only (403for a visible non-owner); update withmanageon the space succeeds,readonly403, no read404; owner create needs no step-up; every allowed mutation has one audit entry.TestSpaceEvents:space.created,space.updated(update, move),space.archivedandsidebar.updatedhave the envelope of 04 "2.2", nonull, correct resource versions;sidebar.updatedcarriesaudience = self.TestSidebarPut: last write wins across two devices withupdated_by_device_id;use_global_orderremoves the key; both fields400; invalid key400; agent token403; invisible space404; repeating the same PUT is idempotent in effect.TestListSidebarStatesPort: returns only the caller's rows of visible, non-archived spaces.- Every response validates against openapi.yaml in the test build.
Commands that must pass:
cd services/server && go test -race ./internal/chat/... ./internal/authz/... ./internal/kernel/...
make codegen-check
make api-lint
make migrations-lint
make arch
make check
Acceptance criteria¶
- Moving a space changes exactly one row; order is stable and total under concurrency.
-
updateSpacerequiresIf-Match. - Hex values and emoji as color or icon are rejected; palette keys are accepted.
-
space.readholds withreadon only one channel. -
space.archiveis a permission change withaccess.changed; archived spaces are read-only for everyone and missing fromlistSpaces(07 "4.5"). - Events
space.created,space.updated,space.archived,sidebar.updatedare written as specified. - Names with control or bidi characters are rejected.
- New spaces are visible to owners and instance-level readers without a new permission version.
- Spec changes are in the same change;
make api-lint,make api-checks,make codegen-check,make migrations-lint,make arch,make config-doc-checkare green; the seven operations are enabled. -
make checkis green.
Definition of done¶
Code and tests as above; internal/chat/README.md created or updated (ports, tables, actions,
events, rules); doc comment on every public symbol; an ADR only for an architecture decision beyond
the rulings listed; services/server/CHANGELOG.md entry under Unreleased; rulings in
docs/DECISIONS.md; make check green; an independent review and the red team
(docs/quality/red-team-gate.md) over the diff with the verdict recorded; 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 commits,
files with line counts, test results with counts (including the red runs), what is not proven,
follow-ups that could not be done in scope with reason. Then write {{DONE_PATH}} with
exit=0 sha=<sha> or exit=1 <reason>.