M1-25: Action catalogue in code and registration duty¶
Status: ready | Depends on: M1-18 | Milestone: M1
Goal¶
After this ticket the server carries the closed action catalogue of chapter 05 as generated Go
code: typed identifiers for every action, event type, WebSocket op and resource kind in
internal/kernel/authorize, and the catalogue metadata (resource kinds, permission, owner,
step-up, audit, permissions, rate-limit class) in internal/authz. The server refuses to start
when a registered route, op or event type is missing from the catalogue, names a system action or
disagrees with the catalogue's rate-limit class, and an unknown action ends in 500 with an
error log. This makes B7 and ADR 0017 hold mechanically before authorize itself exists
(M1-28).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 7 (M1) with the rows M1-02, M1-17, M1-18, M1-25, M1-28, M1-67 and the subsection "Prerequisites from the core contract".docs/spec/05-actions.md: section 1 rules 1, 3, 4, 5 and 10; section 2.1 to 2.5; section 3 "Rate-limit classes"; section 4 (all action tables); section 6 (durable and ephemeral event types with receive action); section 7 (WebSocket ops); section 9 "Tests that hold this catalogue".docs/spec/actions.yamland the schema or README oftools/catalog/(both from M1-02): this is the only input of the generator.docs/spec/07-permissions.md: section 4.1 (reasonunknown_action), section 4.2 (first three lines of the pseudo-code), section 11.2 (AZ-P1, AZ-P2, AZ-P3).docs/spec/02-architecture.md: section 4.5 "Cross-cutting ports" (kernel/authorize), 4.6, 4.7, 5 "Layers within a module", 6 "Code layout", 7 "Automatic check of the rules", 9.3 "Proof of the check", 14.1 "Error kinds", 15.1 "Start" step 7.docs/spec/06-api.md: section 4 "Authorization in the API", section 5 "Errors" (problem codeinternal_error).- ADRs:
docs/adr/0017-one-authorization-function-404-instead-of-403-mandatory-registration.md,docs/adr/0008-hexagonal-modules-and-checked-dependency-rules.md,docs/adr/0013-spec-first-openapi-31-and-asyncapi-30-normative.md, ADR 0049 (machine-readable catalogue, written by M1-02). - Code of earlier tickets (read before writing anything):
services/server/internal/kernel/authorize(M1-05 skeleton, M1-18 check log),services/server/api/routesandservices/server/api/operations.yaml(M1-17),services/server/internal/wire,services/server/internal/platform/httpserver(M1-18),services/server/internal/kernel/errs, the location oftools/archgen(M1-05), thecodegen-checktarget inMakefile(M1-01),.semgrep/bothy-go-route-registration.yamlwith its fixture (rule format). docs/quality/gates.md,CONTRIBUTING.md,docs/quality/push-discipline.md,docs/quality/red-team-gate.md.- API operations: none are added or changed. Tables: none. Action ids: all of them (the whole catalogue).
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.
- Test first: every behaviour gets a test that is red before the code exists (plan section 3).
- No en dash or em dash in any text you write; white label (no company, person, host or path
names; examples use Alex, Sam, Robin and example.com); configuration only through named keys
from
docs/operations/configuration.md. - B7 and 05 rule 3: every registered route carries a known action and its rate-limit class; the server checks this at start (02 section 15.1 step 7).
- 05 rule 4 and 07 AZ-P2: a WebSocket op or event type without catalogue entry must not be registrable.
- 05 rule 5 and 07 AZ-P3: an unknown action is a programming error; it ends in
500and an alarm (here: an ERROR log line; see Design notes), never in an allow. - 05 rule 10 and section 9 test 7: no route and no op names an action whose permission is
system.
Scope¶
All paths below are relative to the repository root unless they start with internal/, api/
or tools/catalogen, which are relative to services/server/.
- Generator
services/server/tools/catalogen/(package mainplus an internal package for the logic, place it next totools/archgenfrom M1-05; if M1-05 putarchgenelsewhere, mirror that location). It readsdocs/spec/actions.yamland writes two files:internal/kernel/authorize/catalog_ids_gen.go: typesAction,EventType,Op,ResourceKind(allstringbased), one exported constant per catalogue entry, the parse functionsParseAction(string) (Action, bool),ParseEventType(string) (EventType, bool),ParseOp(string) (Op, bool),ParseResourceKind(string) (ResourceKind, bool), andAllActions() []Action,AllEventTypes() []EventType,AllOps() []Op,AllResourceKinds() []ResourceKind(sorted ascending, fresh slice per call).internal/authz/domain/catalog_gen.go: the metadata as domain values keyed by plain strings (the domain layer may not importkernel/authorize, 02 section 5). Both files start with the SPDX line// SPDX-License-Identifier: Apache-2.0followed by// Code generated by catalogen. DO NOT EDIT.and are excluded from coverage and Sonar like other generated code (configuration from M1-05).
- Wiring of the generator: a
//go:generatedirective ininternal/kernel/authorize/doc.go://go:generate go run ../../../tools/catalogen -catalog ../../../../../docs/spec/actions.yaml -ids catalog_ids_gen.go -domain ../../authz/domain/catalog_gen.go. Make suremake codegen-checkruns it (ifcodegen-checklists generators explicitly, add this one). - Catalogue types in
internal/authz/domain/catalog.go(hand-written):ActionSpec,EventSpec,OpSpecand the lookupsLookupAction(id string) (ActionSpec, bool),LookupEvent(t string) (EventSpec, bool),LookupOp(op string) (OpSpec, bool),ActionIDs() []string. - Catalogue service in
internal/authz/app/catalog.go:type Catalog struct,NewCatalog(log *slog.Logger) *Catalog,(*Catalog) Spec(a authorize.Action) (domain.ActionSpec, error)and(*Catalog) VerifyRegistrations(routes []authorize.RouteBinding, reg *authorize.Registry) error. - Registry in
internal/kernel/authorize/registry.go:type Registry,NewRegistry(),(*Registry) Event(module string, t EventType),(*Registry) Op(module string, op Op),(*Registry) Events() []EventRegistration,(*Registry) Ops() []OpRegistration, andtype RouteBinding struct { OperationID, Method, Pattern, Action, RateLimitClass string }. Modules register through a functionRegister(r *authorize.Registry)in their port package;internal/wirecalls these functions. No module has to register anything in this ticket; later tickets add their types (for example M1-29 registersaccess.changed). - Start check in
internal/wire: build the registry, convert the generated route table of M1-17 to[]authorize.RouteBinding, callVerifyRegistrationsin the start step that M1-17 uses for its route check (02 section 15.1 step 7). A failure abortsbothy servewith exit code 1 and one log line per violation. M1-17 passesroutes.SpecCatalog()toroutes.Buildininternal/wire; replace that argument with the closedauthzcatalogue so there is exactly one definition of "known". - No second constant set: if
api/routes(M1-17) declares its own action constants, makekernel/authorizethe only declaration and regenerateapi/routesto refer to it. - Semgrep rule
.semgrep/bothy-go-catalog-id-conversion.yamlwith fixture.semgrep/bothy-go-catalog-id-conversion.go: forbids converting a string toauthorize.Action,authorize.EventType,authorize.Oporauthorize.ResourceKindoutside files named*_gen.go,*.gen.goand*_test.go(if M1-17's generated route table uses another file name, add exactly that path to the rule's exclusions). Code obtains identifiers only from constants or the generatedParse*functions. - Documentation:
internal/authz/README.md(catalogue, start check, how a module registers ops and event types), package documentation ofkernel/authorize, a sentence indocs/spec/02-architecture.mdsection 4.5 rowkernel/authorizethat namesRegistry,services/server/CHANGELOG.mdunderUnreleased.
Out of scope¶
authorize,Decision,Reason, the resource descriptor and theAuthorizerinterface: M1-28. Do not add a decision function here.- The request check log and
authz_not_checked: M1-18 (already merged; use it, do not change it). - Generating
api/routesfromopenapi.yamland the operation allow-list: M1-17. - The catalogue file itself, its Python checks and the generated tables in 05 and
docs/api/redocly.yaml: M1-02. Do not editdocs/spec/actions.yamlor chapter 05. - Consistency of 02 tables with the register: M1-06. Frame conformance of the gateway: M1-67.
- Registering real event types or ops of modules: each module's own ticket.
- Metrics such as
bothy.authz.decisions: M1-68.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-25-authz-catalogue. - Conventional Commits with scope (
feat(authz): ...,build(server): ...) and DCO sign-off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except
services/server/CHANGELOG.md,docs/DECISIONS.md(only if you record a ruling) and the documentation files named in Scope. - Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them by their literal path at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- If
docs/spec/actions.yamllacks information this brief requires (see Design notes, input contract), stop withVerdict: BLOCKED(plan P-11); do not invent catalogue content.
Design notes¶
Input contract. The generator needs, per action: id, resource kinds, permission (as written in
05), owner, step-up, audit (as written in 05), permissions, rate-limit class (none for no class);
per event type: type, durable or ephemeral, resource kind, receive action (as written in 05),
audience self or not; per op: name(s), direction, action(s); per channel op (M1-02 channel_ops, the
terminal channels of 05 section 7): channel, op name(s), direction, action. Use the field names
M1-02 chose.
Decode strictly (unknown keys are an error; use the YAML module already required in
services/server/go.mod, do not add a second YAML library).
Normalization rules (only for fields that M1-02 stores as 05 text):
| Field | Accepted input | Output |
|---|---|---|
| owner, step-up, permissions | yes, no (or YAML booleans) |
bool |
| step-up | additionally conditional and fresh |
StepUp = true plus StepUpConditional or StepUpFresh |
| rate-limit class | a class from 05 section 3, or none |
class string, "" for none |
| resource | a list of kind names, or 05 text: comma-separated kind names, each optionally followed by a parenthetical note (dropped), or the literal as the target action |
[]string (empty for as the target action) |
| permission, audit, receive action, op action | any text | stored raw in Right, Audit, ReceiveAction, ActionsText |
| mentioned actions | every token in the raw text (after removing backticks) that equals a catalogue action id | MentionedActions []string, sorted |
| op names | one name, or several separated by commas | one OpSpec per name |
Anything else fails generation with a message naming the entry and field. System is true
exactly when the permission text equals system.
Identifier rule. Split the id on . and _, capitalize the first letter of each part,
join, add the prefix: Action for actions, Event for event types, Op for ops, Resource
for resource kinds. Examples: message.read gives ActionMessageRead,
agent.billing_mode.set_all gives ActionAgentBillingModeSetAll, access.changed gives
EventAccessChanged, command.autocomplete_result gives OpCommandAutocompleteResult,
auth_session gives ResourceAuthSession. Two ids that map to the same identifier fail
generation. Ids must match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$.
Determinism. Output sorted by id, gofmt-formatted, no timestamps; two runs give identical
bytes.
Domain types (internal/authz/domain/catalog.go):
type ActionSpec struct {
ID string
Resources []string
Right string // raw 05 text, interpreted by M1-28
Owner bool
StepUp bool // true for "yes" and for "conditional" (05 2.4; until the ticket
// that supplies the condition, "conditional" counts as "yes", 07 4.2)
StepUpConditional bool // "conditional": M1-39 and M1-40 add the condition in authz
StepUpFresh bool // "fresh" (05 2.4): a fresh, unconsumed step-up that the action
// consumes; only the terminal actions of 05 4.14, deferred until
// M2-40, which evaluates it
Audit string // raw 05 text
Rights bool // the action raises permission_version (05 rule 7)
RateLimitClass string // "" when 05 says "none"
System bool // Right == "system" (05 rule 10)
Effect string // "read" or "write" (05 2.7); used by the maintenance layer (M7-08)
}
type EventSpec struct {
Type string
Durable bool
Resource string
ReceiveAction string // raw 05 text
MentionedActions []string
AudienceSelf bool
ResourceVersion bool // the envelope carries resource.version (04 "Delivery guarantee" 3)
}
type OpSpec struct {
Op string
Direction string // raw 05 text
ActionsText string // raw 05 text
MentionedActions []string
}
type ChannelOpSpec struct { // terminal channels (05 section 7, second table); not gateway ops
Channel string // terminal_stream, host_control, host_data
Op string // message name; noise_frame for binary frames
Direction string
Action string
}
Channel ops get their own constants of type ChannelOp (value <channel>/<op>, identifier
ChannelOp plus channel plus op, for example ChannelOpHostControlSessionOpen), the lookup
LookupChannelOp(channel, op string) (ChannelOpSpec, bool) and the registry methods
(*Registry) ChannelOp(module string, op ChannelOp) and ChannelOps(). No module registers one in
M1; the terminal tickets of M2 (M2-43, M2-44) do.
VerifyRegistrations collects all violations (never stops at the first) and returns one error listing them, each naming the operation id, op or event type:
- Route:
Actionparses and is in the catalogue; the action is notSystem;RateLimitClassequals the catalogue class (an action with class "" can never be a route). - Op: the op is in the catalogue; its direction is client or agent to server (server-to-client
ops are never registered); every mentioned action is known and not
System. - Event type: the type is in the catalogue; every mentioned action of its receive action is
known; no type is registered by two modules.
3a. Channel op: the pair of channel and op is in the catalogue and its action is not
System; a gateway op name never counts as a channel op and the reverse. - An op or event type registered twice by the same module is one registration (idempotent).
Unknown action. Catalog.Spec with an action that is not in the catalogue returns a
kernel/errs error of kind internal with code internal_error and cause unknown_action (02
section 14.1), writes one ERROR log record with the attributes bothy.action (the offending
value; it comes from code, never from user input) and error.type=unknown_action, and increments
bothy.authz.errors with bothy.reason = unknown_action (signals.md; alarm BothyAuthzError).
The HTTP chain of M1-18 renders it as 500 internal_error (06 section 5); the action value never
appears in the response. Every Authorizer implementation (M1-28) must start with
Catalog.Spec.
Why a registry instead of reading asyncapi.yaml at run time: the registry lists what this binary actually emits and accepts, which is what AZ-P2 asks; the documents are checked against the catalogue by M1-03 and M1-67.
Tests (write them first)¶
Go tests run with -race. Test names carry the AZ id where one applies.
tools/catalogen: golden test ontestdata/valid.yaml(a small catalogue with at least one action of each normalization case includingfresh, one durable and one ephemeral event, one op row with two names, one channel op row) against checked-in golden outputs;TestGenerate_Deterministic(two runs, equal bytes); failure cases, each with its own fixture and an assertion on the message: duplicate action id, identifier collision (a.b_canda_b.c), unknown key, owner valuemaybe, unknown rate-limit class, resource textthread or channel, idMessage.Read.internal/authz/domain:TestLookupAction_Knownformessage.read(resourcesmessage, permissionread, classrl.read, not system) andinstance.bootstrap(system, class "");TestLookupAction_Unknown;TestEventSpecs_DurableAndEphemeralformessage.createdandtyping.started;TestOpSpecs_MentionedActionsforheartbeat(mentionsgateway.connectandagent.heartbeat).internal/authz/app:TestAZP1_RoutesNeedKnownActionAndClass: the real route table ofapi/routespasses; fixtures with an unknown action, withinstance.bootstrap, and with a wrong class each fail and the error names the operation id.TestAZP2_OpsAndEventTypesNeedCatalogueEntry: the real registry built byinternal/wirepasses; a registry withauthorize.EventType("no.such.event"), withauthorize.Op("no_such_op"), with the server-to-client ophello, and with one event type registered by two modules each fails.TestAZP3_UnknownActionFailsClosed:Spec(authorize.Action("no.such.action"))returns kindinternalwith causeunknown_action, exactly one ERROR record (capture with a testslog.Handler) and one increment ofbothy.authz.errors{bothy.reason=unknown_action}(manual metric reader).
internal/platform/httpserver(or wherever M1-18 keeps its chain tests):TestAZP3_UnknownActionIs500: a test route whose handler returns the error ofCatalog.Specfor an unknown action answers500, problem codeinternal_error, and the body does not contain the action string.internal/wire:TestStartAbortsOnBadRegistration: the start step with an injected bad route binding returns an error and does not open listeners (reuse M1-17's start-abort test pattern)..semgrep/bothy-go-catalog-id-conversion.go:ruleidcases for each of the four conversions,okcases for constants andParse*calls.
Commands that must pass:
cd services/server && go generate ./internal/kernel/authorize/... && git diff --exit-code
cd services/server && go test -race ./tools/catalogen/... ./internal/kernel/authorize/... ./internal/authz/... ./internal/wire/...
make codegen-check
make arch
make semgrep
make check
Acceptance criteria¶
-
make codegen-checkregenerates both files without diff. - Every action, event type, op and resource kind of
docs/spec/actions.yamlhas exactly one constant; no other constants of these types exist outside the generated file. - AZ-P1, AZ-P2 and AZ-P3 are green under these names.
-
bothy serveexits with code 1 and names each violation when a route, op or event type breaks a rule of VerifyRegistrations. - Generator failure cases from the Tests section each fail with a message naming entry and field.
- The Semgrep rule finds all four conversions in its fixture and nothing in the code base.
-
internal/authz/README.mdexplains the catalogue, the start check and how modules register; every exported symbol has a doc comment. -
make checkis green.
Definition of done¶
Code, tests and documentation are complete: module README updated, doc comments on every public
symbol, an ADR only if you took an architecture decision not covered by ADR 0017 and ADR 0049,
CHANGELOG entry under Unreleased in services/server/CHANGELOG.md. make check is green.
An independent review and the red team (docs/quality/red-team-gate.md) have run over the diff
with their verdicts recorded in the report. Merge locally into the base branch only if both are
green; then remove the worktree with git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red run of each
new test before the code), what is not proven, and follow-ups that could not be done in scope with
the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.