M1-53: Channels and forum tags¶
Status: ready | Depends on: M1-52 | Milestone: M1
Goal¶
After this ticket the server manages text and forum channels inside spaces: list, create, read,
rename, reorder and archive them, every text channel with exactly one main thread, and forum tags
with restricted tags. Channels become part of the real authz.StructureSource, so authorize
resolves channel paths, and a channel created in a visible space is visible and catch-up relevant
at once, without a new permission version. Operators (M1-54), threads (M1-55) and messages (M1-56)
build directly on this.
Read first¶
docs/spec/03-data-model.md: "Principles" (all 13 points), "Modules", section "Structure (chat)" tableschannels,channel_skill_bindings,forum_tags,threads; "Transaction rules" (6.1 global sequence, 6.3 lock order, 6.4 I1); decisions R-DM-10, R-DM-11, R-DM-12, R-DM-21, R-DM-25; "Configuration values of this chapter".docs/spec/04-sync-protocol.md: "Envelope", "Global, commit-ordered sequence", "Idempotent writes" (5.2), "Catch-up" 7.2 (fully visible spaces), "Visibility on permission changes" 8.1 and 8.2, "Delivery guarantee".docs/spec/05-actions.md: rules 1.1, 1.2, 1.3, 1.5, 1.7, 1.8; "Target of a resource"; "Effective level"; 'Columns "Owner", "Step-up", "Audit", "Permissions"'; "Decision"; table "Spaces, channels, sidebar" rowsspace.read,channel.read,channel.create,channel.update,channel.reorder,channel.archive,forum_tag.manage; section 6 rowschannel.created,channel.updated,channel.archived,thread.created; decisions R-AZ-01, R-AZ-06, R-AZ-12.docs/spec/07-permissions.md: "Targets and inheritance", "Theauthorizefunction" (4.1 reasons, 4.2 flow, 4.3 visibility, 4.4 invariants), "Filtering in SQL" (7.2 snapshot, 7.3 cache validity, 7.4 query pattern), "Immediate effect on revocation", "Required tests" AZ-P1, AZ-P4, AZ-P8.docs/spec/02-architecture.md: "Modules of the monolith" 4.1 to 4.6, "Layers within a module", "Code layout", "Transactions and Unit of Work", "Request path", "Permission cache" (11.3), "Alignment with the core contract" row "catch-up with fully visible spaces; the cache follows structure events".docs/spec/06-api.md: "Basic rules", "Authorization in the API", "Errors", "Pagination" point 6, "Idempotency", "Concurrency", endpoint table "Channels".docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-T07.docs/spec/01-product.md3.1 and 3.2 row "Operator per channel visible in the channel header".docs/api/openapi.yaml, operationIds:listSpaceChannels,createChannel,getChannel,updateChannel,moveChannel,archiveChannel,listForumTags,createForumTag,updateForumTag,moveForumTag,deleteForumTag. Schemas:Channel,ChannelView,ChannelViewList,ChannelCreate,ChannelUpdate,ChannelOperator,MovePosition,ReasonBody,ForumTag,ForumTagList,ForumTagCreate,ForumTagUpdate,ForumSort,ChannelCreatedEvent,ChannelUpdatedEvent,ChannelArchivedEvent,ThreadCreatedEvent,Problem.docs/api/asyncapi.yaml: messagesevent.channel.created,event.channel.updated,event.channel.archived,event.thread.created.- ADRs: 0008, 0009, 0013, 0015, 0017, 0018; 0050, 0052, 0053 (written by M1-05 and M1-16).
- Code of merged tickets you build on:
services/server/internal/chat(M1-52: spaces, sidebar, fractional index helper, palette validator, the adapter implementingauthz.StructureSource),internal/authz(M1-27 to M1-29: snapshot cache,authorize,Scope,RightsChangephase hook),internal/kernel/{txn,eventlog,auditing,authorize,actor,ids,errs},internal/platform/httpserver,services/server/api/operations.yaml, the display-name validator of SEC-T07 (M1-26), the response validator used in integration tests. docs/quality/gates.md,CONTRIBUTING.md,docs/PLAN.mdsections "Way of working per ticket" and "M1 Foundation".
Fixed requirements that apply¶
Owner build rules (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.
Note on rule 3: in this repository docs/api/openapi.yaml is the source and the server interface
is generated from it (ADR 0013); the drift check make codegen-check keeps routes and document
identical, which is how the rule is met.
Rules for every brief:
- Test first: every behaviour change starts with a failing test (red), then code (green), then cleanup. Keep the red output for the report.
- No dashes U+2013 or U+2014 in any file you write; use a hyphen or rephrase.
- White label: no company, person, host or domain names except the examples of the specification (example.com, Alex, Sam, Robin, the example spaces and agents of 07 "Examples").
- Limits and windows only through named configuration keys from
docs/operations/configuration.md; never a literal number in code or docs that a key covers.
Product requirements implemented here:
- Channel kinds
textandforum, each in exactly one space;dmcannot be created through these operations (03channels, R-DM-10). - Every text channel has exactly one thread with
kind = 'main', created in the same transaction; forum channels have none (R-DM-11). - Channel and tag order is a fractional index; moving changes exactly one row (03 "Principles" point 5).
- Channel names are unique per space, case-insensitive, among non-archived channels; tag names
unique per channel among non-deleted tags (03 unique indexes). Channel names at most
channel.name_max_chars, topics at mostchannel.topic_max_chars, tag names at mostforum_tag.name_max_chars(03 9). Display texts with control or bidi control characters are rejected (SEC-IN-4, SEC-T07). - No read permission gives
404, identical to an unknown ID; lists filter in SQL and then runauthorizeper item (05 rule 1.2, 07 7.4). channel.archive: permissionmanage, step-up, audit, and a permission change withaccess.changed(05 table, rule 1.7).- A new channel in a visible space is visible and catch-up relevant without a new permission version (02 "Alignment with the core contract", 04 7.2 and 8.1).
- Forum tags are managed with
manage; tags may berestricted(03forum_tags, 05forum_tag.manage). - Every durable event uses the common envelope without JSON
null(04 2.2, ADR 0053).
Scope¶
Create or change exactly these (paths relative to services/server/ unless they start with docs/):
migrations/<next number>_chat_channels.sql(goose, same numbering format as the existing files): tableschannels,channel_skill_bindings,forum_tags,threadsexactly as 03 defines them, with these deliberate deviations, each commented in the file:threads.search_language,threads.title_searchand its GIN index are not created here; M3-13 adds them (plan row M3-13).- The foreign key
threads.parent_message_id -> messages(id)is not created here becausemessagesdoes not exist yet; M1-56 adds it asDEFERRABLE INITIALLY DEFERRED. - All other columns, defaults, CHECKs, unique and partial indexes of the four tables are
created as written in 03, including
channels.read_only, the behaviour columns andsettings_version(their operations come in M2-02 and M8-03; the columns exist now so no later migration rewrites the table). - Privileges for the application role follow the pattern of the existing migrations
(
grep -n GRANT migrations/*.sql). - Add the foreign key
grants.target_channel_id -> channels(id) ON DELETE RESTRICT(03grants), which M1-27 could not create beforechannelsexisted.
internal/chat(port): extend theStructureport with the channel and forum tag use cases below and their DTOs; add the in-transaction event typeChannelArchived{ChannelID, SpaceID, ArchivedAt}(02 4.3). No subscriber is registered in this ticket.internal/chat/domain:Channel,ChannelKind,OperatorMode(read side only:inherit,none,agent), name and topic validation,ForumTag, the archive transition, and theMovePositionrule.internal/chat/app: use casesListSpaceChannels,CreateChannel,GetChannel,UpdateChannel,MoveChannel,ArchiveChannel,ListForumTags,CreateForumTag,UpdateForumTag,MoveForumTag,DeleteForumTag.internal/chat/adapters/postgres:queries/channels.sql,queries/forum_tags.sql,queries/threads_main.sql(insert of the main thread only), sqlc output, repository.internal/chat/adapters/httpapi: handlers for the eleven operations above.- The chat adapter that implements
authz.StructureSource(find it withgrep -rn "StructureSource" internal/chat): the channel part, see "Design notes" D7. internal/authzandinternal/kernel/authorize, only as far as "Design notes" D8 and D9 require (archived cap, effective permissions forChannelView), each with tests. Permissions of channels created after a snapshot and the list scope fields are built by M1-28 (07 7.2, ruling D5 there); this ticket only adds the integration test against real channels. The target-basedRightsChangecomes from M1-29.api/operations.yaml: enable the eleven operations; regenerateapi/restandapi/routeswith the generator target documented inservices/server/api/README.md(M1-17).internal/platform/configschema: registerchannel.name_max_chars,channel.topic_max_charsandforum_tag.name_max_chars(positive integers, defaults from 03 section 9) in the typedchatconfig, the same way M1-52 registersspace.name_max_chars; addchannel.history_backfill_limit_defaultandchannel.history_backfill_limit_maxwith the defaults of 03 if they are not there yet.internal/wire: register handlers and the new in-transaction event type; update the golden file of subscriptions.- Documentation:
internal/chat/README.md(status, ports, tables, actions, events),services/server/CHANGELOG.md(Unreleased),docs/DECISIONS.md(rulings D1, D3, D6, D8 below); the description ofarchiveChannelindocs/api/openapi.yamlalready states the rule of 07 "4.5". 07 "4.1", "4.5" and 06 (problem codechannel_read_onlywithreason: channel_archived) already state D2; if the M1-03 generator owns the 06 problem block, keep its source in step (D10).
Public interface (pseudo-Go; follow the naming and error conventions already used in
internal/chat for spaces):
// Structure (excerpt added by this ticket). All methods run inside an ambient unit of work
// when one exists, else they open their own (kernel/txn).
ListSpaceChannels(ctx, spaceID uuid.UUID) ([]ChannelView, error)
CreateChannel(ctx, CreateChannelCmd) (Channel, error) // kind, name, topic, forum_default_sort, after_channel_id
GetChannel(ctx, channelID uuid.UUID) (ChannelView, error)
UpdateChannel(ctx, UpdateChannelCmd) (Channel, error) // expected version from If-Match
MoveChannel(ctx, MoveChannelCmd) (Channel, error) // after_id | before_id | none
ArchiveChannel(ctx, ArchiveChannelCmd) (Channel, error) // reason
ListForumTags(ctx, channelID uuid.UUID) ([]ForumTag, error)
CreateForumTag(ctx, CreateForumTagCmd) (ForumTag, error)
UpdateForumTag(ctx, UpdateForumTagCmd) (ForumTag, error)
MoveForumTag(ctx, MoveForumTagCmd) (ForumTag, error) // channel id, tag id, MovePosition
DeleteForumTag(ctx, channelID, tagID uuid.UUID) error
The actor comes from kernel/actor in the context, never from a parameter.
Out of scope¶
- Setting operators (
setSpaceOperator,setChannelOperator), the operator rule and the I1 served targets: M1-54. This ticket only readsoperator_inherit,operator_agent_idand the space default to fillChannel.operator. - Thread operations,
thread_tags, thread paths inStructureSource: M1-55. Removingthread_tagsrows when a tag is deleted is added by M1-55. - Messages and
createThread: M1-56. Moving threads: M1-62. - Channel behaviour (
getChannelBehavior,putChannelBehavior), skills,channel.settings_changed: M2-02. Project binding: M2-03. Settingread_only(setChannelReadOnly): M8-03; the column exists with defaultfalseand nothing in M1 sets it. - Direct messages and
dm_participants: V3. - Search columns and indexes: M3-13. Notification preference cleanup on archive: M3 (
notifysubscribes toChannelArchivedlater). kernel/personaldata.Participantfor chat tables: M7 (P-14).- Applying structure events to the gateway cache (
Authorizer.Observe): M1-65. REST catch-up and snapshot: M1-63.
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-53-chat-channels, created from the base branch the launcher names. One writer per worktree. - Conventional Commits with a module scope (for example
feat(chat): channels and forum tags), every commit with DCO sign-off (git commit -s). Squash to 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 list, except
services/server/CHANGELOG.md,docs/DECISIONS.mdand the module README named above. - Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0. PostgreSQL 18 runs as a throwaway container throughinternal/platform/pg/pgteston a random port; a missing container runtime fails the test, it never skips (P-09). - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end by their literal path. - Never read or print secrets or
.envfiles. - No network calls except package downloads for pinned tools.
- Do not lower a threshold, disable a gate, skip or delete a test.
Design notes¶
D0. Start check. Run make check on the fresh worktree before changing anything. If it is red,
stop with Verdict: BLOCKED base branch red and the failing gate. Confirm that M1-52 is merged
(internal/chat has spaces and a StructureSource adapter); otherwise stop with
Verdict: BLOCKED dependency M1-52 missing.
D1. Channels.
createChannel: actionchannel.createon the space (permissionmanage, audit). BodyChannelCreate.kindistextorforum.name: trim leading and trailing Unicode white space, then at least one character; reject C0 and C1 control characters and bidi controls (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069) with400 validation_failed, field error/namecodeinvalid_characters; reuse the shared validatorkernel/textcheck(M1-26) instead of writing a second one. The same check applies totopic, which is at mostchannel.topic_max_charscharacters (/topic,too_long).forum_default_sortonly forforum, else400(/forum_default_sort,not_allowed_for_kind).after_channel_idmust be a non-archived channel of the same space, else400(/after_channel_id,not_a_sibling); absent means at the end. Defaults from 03;history_backfill_limitis set tochannel.history_backfill_limit_default;operator_inherit = true. A duplicate name gives409 conflict. An archived space gives409 conflict.- For
kind = 'text'the same transaction inserts the main thread:kind = 'main',title = '',title_source = 'auto',state = 'open',operator_agent_idNULL,parent_message_idNULL,created_by= actor. - Events in this order:
channel.created(scope{space_id, channel_id}, resource{channel, id, version}, payload{channel}), then for text channelsthread.created(scope withthread_id, payload{thread}in theThreadschema withtag_ids: []). Audit allow entry with details{space_id, channel_id, kind, name}. Channelrepresentation:operator = {mode, agent_id?, effective_agent_id?}where mode isinheritwhenoperator_inherit, elseagentwhenoperator_agent_idis set, elsenone;effective_agent_id= space default when inheriting, elseoperator_agent_id; absent fields are omitted, nevernull.main_thread_idfor text channels.read_only,archived_at,version,settings_version,created_at. Never the prompt or behaviour fields.getChannel: actionchannel.read;ETag: "<version>"; returnsChannelViewwith the caller's effectivelevelandcapabilitieson the channel (D9). Archived channels are returned witharchived_at.listSpaceChannels: firstauthorize(space.read, space), invisible space gives404; thenScope(channel.read); SQL filterspace_id = $space AND archived_at IS NULL AND (id = ANY($visible) OR space_id = ANY($fully_visible_spaces) OR $instance_wide), ordered bysort_key; thenauthorize(channel.read)per item in filter modeauthorize.Filter(ctx)(M1-28; no denial audit, 07 4.2), an item that fails is dropped and counted as an alarm (07 7.4 rule 3). Unpaginated (06 "Pagination" point 6). Archived channels are not listed.updateChannel: actionchannel.update(manage, audit),If-Matchrequired (428without,412 version_conflictwithcurrenton mismatch, through the helpers of M1-18). At least one field, else400.default_notification_levelandclear_default_notification_level: truetogether give400. Unchanged values give200with the current channel, no version bump, no event. A change bumpsversionand emitschannel.updated. Audit details: changed field names, old and newname; never the topic text.moveChannel: actionchannel.reorderon the space (manage, audit).MovePosition: bothafter_idandbefore_idgive400; a neighbour that is not a non-archived channel of the same space gives400; none moves to the start. Compute the key with the fractional index helper of M1-52; exactly one row changes. Bumpsversion, emitschannel.updated.
D2. Archived channels (07 "4.5 Archived spaces and channels", reason channel_archived in 07
"4.1"):
archiveChannel(actionchannel.archive:manage, step-up, audit, permissions) needsReasonBody; it setsarchived_at, bumpsversion, emitschannel.archived(payload{channel}) and publishesChannelArchivedin the transaction. It is irreversible; 05 has no unarchive action. Archiving an archived channel returns200with the current channel and writes nothing.- On an archived channel, and on every channel of an archived space, the effective level of
every principal, owners included, is at most
read.authorizedenies every action whose "Permission" column needswriteortriggeron a target in that channel with decisionforbidden, reasonchannel_archived. The HTTP answer is403with problem codechannel_read_onlyandreason: channel_archived(same client reaction as rule 1.8, no new problem code). Reading stays allowed for everyone who could read before. - Actions with permission
manageorownerpassauthorize, but chat rejects every state change of an archived channel or its tags (updateChannel,moveChannel, forum tag create, update, move, delete) with409 conflict. - Permission change: the unit of work runs with the
RightsChangeoption. Affected principals are all principals whose effective level on the channel was abovereadbefore the change (direct grants, group members, owners, the operator rule).authzwritesaccess.changedfor each with achangedentry{kind: channel, id, level: read, previous_level}and bumpspermission_version(04 "8.1" and 02 "11.3": archiving is a structure change and a permission change). listSpaceChannelsomits archived channels;getChannelstill returns them.
D3. Forum tags.
listForumTags: actionchannel.read; non-deleted tags ordered bysort_key.createForumTag,updateForumTag,deleteForumTag: actionforum_tag.manage(manage, audit). The channel must be a forum, else400(/,not_a_forum). Name rules as D1; unique case-insensitive among non-deleted tags of the channel, else409 conflict.colormust be a category colour key of the design system (reuse the palette validator of M1-52; a hex value or unknown key gives400).restricteddefaults tofalse.after_tag_idmust be a non-deleted tag of the same channel, else400; absent means at the end.updateForumTag: at least one field, else400;clear_colorremoves the colour and together withcolorgives400(/clear_color,not_allowed_with_color); no change gives200without event.moveForumTag: actionforum_tag.manage, bodyMovePositionwith the rules ofmoveChannelamong the non-deleted tags of the channel; setssort_key.deleteForumTag: setsdeleted_at; deleting a deleted tag gives204; a tag ID that never belonged to this visible channel gives404.- Events (03
forum_tags, 05 section 6 rowchannel.updated): every tag create, update, move or delete bumpschannels.versionand emitschannel.updated; clients reloadlistForumTagswhen the channel version changes. Audit details{op, tag_id, name}.
D4. Idempotency and concurrency. POST and PATCH operations listed above accept Idempotency-Key
(M1-20 middleware, nothing to build). Lock order inside a transaction follows 03 6.3: the
permission_version counter first (only archiveChannel), then domain rows in ascending id.
D5. Events go through kernel/eventlog.Appender; scope fields that do not apply are omitted;
resource.version is the version after the change; no JSON null anywhere (checked in
TestCreateChannel_EventsAuditAndEnvelope).
D6. Main thread creation without a thread module: the main-thread insert lives in the chat
repository (queries/threads_main.sql) because chat owns threads; M1-55 builds the thread use
cases on the same table. Record that this ticket creates threads although the plan row of M1-55
names it, because R-DM-11 needs the main thread at channel creation.
D7. authz.StructureSource, channel part, in the chat adapter (keep the interface names M1-28
defined; if a capability is missing, add it to the interface in internal/authz and to its fakes):
resolve a channel ID to {space_id, kind, archived (own or space), read_only}; enumerate the
non-archived channel IDs of a space and of the instance where authz needs them. Lookups run in
the ambient transaction when one exists, so rows written earlier in the same unit of work are seen.
D8. Permissions of channels created after a snapshot (requirement 16). M1-28 builds this (07 7.2,
ruling D5 of M1-28): authorize derives a channel's level at decision time from the freshly
resolved path, the snapshot caches grant data per target only, and Scope returns
visible_channel_ids, fully_visible_space_ids and instance_wide, as 04 7.2 needs; AZ-P8 in
M1-28 already covers channels created after the snapshot. This ticket does not change that core;
it adds the integration test below against the real chat StructureSource. If the test fails,
stop with Verdict: BLOCKED M1-28 does not follow 07 7.2 instead of changing the cache. The
archived cap of D2 point 2 is implemented in authorize's path evaluation, with
reason channel_archived added to the closed reason list that audit_events.deny_reason is tested
against. The archive runs with the target-based RightsChange of M1-29 (02 8.1), which computes the
affected principals of D2 point 4 inside authz.
D9. ChannelView.level and capabilities come from an authz port method that returns the
caller's effective level and capabilities on a channel (owner: trigger and all capabilities;
archived: read). If kernel/authorize has none, add EffectiveRights(ctx, principal,
channelRef) (Level, []Capability, error) with a table test.
D10. Problem mapping: unknown or invisible space or channel gives 404 not_found; visible without
the permission gives 403 forbidden (level_insufficient, capability_missing, owner_only);
missing fresh step-up gives 401 step_up_required (agent, app and personal tokens get 403,
step_up_not_possible). The problem catalogue is generated; change its source (find it with
grep -rn "operator_mismatch" tools docs/api services/server), regenerate with
make api-checks, never edit inside BEGIN GENERATED blocks by hand.
Tests (write them first)¶
Go test names carry the specification ID where one exists, so go test -run AZ_P8 finds it.
Unit (internal/chat/domain, table tests):
TestChannelName_TrimAndEmpty: surrounding white space trimmed; only white space rejected.TestSEC_T07_ChannelAndTagNamesRejectControlAndBidi: every listed code point rejected in name, topic and tag name.TestCreateChannel_ForumSortOnlyForForum.TestChannelAndTagTextLimits: a name overchannel.name_max_chars, a topic overchannel.topic_max_charsand a tag name overforum_tag.name_max_charseach give400 validation_failedwith field error codetoo_longon/nameor/topic; a value of exactly the limit is accepted (limits read from the config, not hard-coded in the test).TestMovePosition_ExactlyOneNeighbour: both set rejected, none means start.TestChannelOperatorRepresentation: inherit, none, agent and the effective agent; no empty fields emitted.
Integration (PostgreSQL 18 via pgtest, real authz, audit, sync modules; HTTP through the
test server and test authenticator of the earlier tickets; every response checked by the response
validator):
TestCreateChannel_TextHasExactlyOneMainThread(R-DM-11): onemainrow; a second direct insert of a main thread for the channel violates the unique index.TestCreateChannel_ForumHasNoMainThread.TestCreateChannel_Rights:manageon the space gives201;writewithoutmanagegives403; no read on the space gives404identical to an unknown space ID.TestCreateChannel_DuplicateNameCaseInsensitive409, and the same name is allowed again after the first channel is archived.TestCreateChannel_ArchivedSpace409.TestCreateChannel_EventsAuditAndEnvelope:channel.createdthenthread.createdinseqorder, scope and resource versions correct,receive_actionvalues from 05 section 6, one audit allow entry, no JSONnullin the stored payloads.TestNewChannelVisibleWithoutNewPermissionVersion: Alex hasreadon space Support; Alex callsgetSpaceto warm the snapshot; the owner creates a channel in Support;permission_versionis unchanged; Alex'sgetChannelgives200andlistSpaceChannelslists it; thechannel.createdrow inevent_logcarriesspace_idandchannel_id;Scopefor Alex lists Support infully_visible_space_ids.TestListSpaceChannels_FiltersInvisibleAndArchived: channel grant only on one channel of the space lists only that channel; archived channels absent; level and capabilities per item.TestGetChannel_ForeignAndUnknownIdentical404(AZ-P4 style): status, headers and body equal after removingcorrelation_idandinstance.TestUpdateChannel_IfMatch:428,412withcurrent,200with version plus one andchannel.updated; no-op gives no event; clearing the notification default works.TestMoveChannel_ChangesExactlyOneRow: row versions of all other channels unchanged.TestArchiveChannel_StepUp: stale step-up gives401 step_up_required; fresh gives200.TestArchiveChannel_RightsChange:permission_versionplus one;access.changedfor Alex (triggertoread) and the owner; none for a principal that only hadread.TestArchiveChannel_WritesDeniedForEveryone: after archiving,authorize(thread.create)for the owner and for Alex givesforbiddenwith reasonchannel_archived;channel.readstill allowed; the audit deny entry carrieschannel_archived.TestArchiveChannel_IdempotentandTestArchivedChannel_Mutations409(update, move, tag create).TestForumTags_CRUD: permissions (manageneeded, read-only member gets403, outsider404),restricted, palette colour accepted, hex colour400, duplicate name409, text channel400 validation_failedwitherrors[].codenot_a_forum, delete twice204,clear_colorremoves the colour,moveForumTagchanges the order, each change emitschannel.updatedwith a higher channel version.TestAZ_P1_RouteCount: the existing route count test passes with the eleven new operations.
Commands that must pass (from the repository root unless noted):
cd services/server && go test -race ./internal/chat/... ./internal/authz/... ./internal/kernel/...
make codegen-check
make arch
make api-lint
make api-checks
make register-check
make config-doc-check
make migrations-lint
make docs
make check
Acceptance criteria¶
- The migration creates the four tables as in 03 with exactly the two documented deviations;
make migrations-lintis green. - The eleven operations are enabled in
api/operations.yaml, generated code has no drift, and every registered route has a known action (AZ-P1). - Every text channel has exactly one main thread; forum channels none.
- A channel created in a visible space is visible over REST without a new permission version,
and its event rows carry
space_id. - Invisible and unknown IDs give identical
404answers. -
archiveChannelneeds fresh step-up, bumpspermission_version, writesaccess.changed, and afterwards no principal, owner included, can write or trigger in the channel (403 channel_read_only, reasonchannel_archived), while reading stays possible. - Forum tags follow D3, including the
channel.updatedruling. - Names with control or bidi characters are rejected (SEC-T07 part channels and tags).
- 07 has the subsection "Archived spaces and channels" and the reason row; 06 and openapi.yaml
describe the archived case;
docs/DECISIONS.mdhas rulings D1 (validation), D2, D3 (tag event), D6, D8. -
make checkis green.
Definition of done¶
- Code, tests and documentation in the same change:
internal/chat/README.mdcurrent (status, ports, tables, actions, events, in-transaction events), a doc comment on every public symbol (purpose, parameters, return value, errors, example where useful), comments explain why. - No new architecture decision beyond the rulings above; if you take one, write an ADR (MADR 4, next free number, headings and text in English) and list it in the report.
services/server/CHANGELOG.mdhas an entry under "Unreleased".make checkgreen on the final commit.- Local review:
make reviewand a code review over the whole diff; every finding fixed with a test. - Red team: a fresh, read-only reviewer runs
docs/quality/red-team-gate.mdovergit diff <base>...HEAD; record its last line (Urteil: JAorUrteil: NEIN <reason>). OnNEINfix, add a test per finding and have the delta checked again. - Merge locally into the base branch only if review and red team are green and
make checkis green; thengit worktree removethe worktree.
Report¶
Write {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), files changed with line counts.
- Test results with counts (passed, failed, skipped must be 0), the red output of the first
failing tests, coverage of
internal/chat. - Review and red team verdicts.
- What is not proven (for example cache behaviour across processes, which M1-65 tests).
- Only real owner decisions, each with a recommendation. A finding in your own diff is fixed, not reported.
Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.