Skip to content

Ruling log: foundation tickets

The first sections of section 10 of the ruling log, from "M1-01 Server gates" to "M1-16 Event log with commit-ordered sequence" in the order they were written, moved here unchanged so that the log stays below repo.max_file_kb (ruling PL/12 there). The rules and the reading guide of the IDs stand in DECISIONS.md; the IDs keep their numbers and meaning.

10. Rulings of the build milestones (first sections)

M1-01 Server gates

No. Date Ruling Reason
M1-01/1 2026-10-10 <module>/migrations.sha256 holds the immutability of merged migrations; only make migrations-lock writes new lines, and it never changes an existing line. By hand, a branch removes only its own line that is not yet merged, when it renumbers. ADR 0007 rule 5 and "Confirmation"; before the first push there is no diff against a remote base branch.
M1-01/2 2026-10-10 The server gates require go, a Docker-compatible runtime and the pins as soon as a Go module is tracked, including gates without tests. R-QA-02 and PLAN P-09: A host that cannot run the integration tests never reports a half-green server state.
M1-01/3 2026-10-10 codegen-check detects drift through snapshots before and after the generator instead of through git diff. Manual changes in a dirty working tree are not drift of the generator.
M1-01/4 2026-10-10 make perf has the time limit quality.perf.timeout. The default limit of go test would end long-running tests early; the value is a named threshold like all others in chapter 23.
M1-01/5 2026-10-10 tools/bin/tools.lock.json is in the layout that make tools-lock writes (JSON with indentation 2); tools/bin/tests/test_fetch_tools.py holds that. In the earlier compact layout, every re-pin rewrote the whole file, and the changed checksums got lost in the diff.
M1-01/6 2026-10-10 make migrations-lock refuses on an unreadable line or a duplicate name in migrations.sha256 and writes nothing; when it writes, it establishes the order and the line ending at the end of the file. With an unreadable or duplicate line it would have to discard or choose a line, that is, change an existing line.
M1-01/7 2026-10-10 migrations-lint skips a Go module only if neither migrations/ nor migrations.sha256 exists; a checksum file without a directory fails. If migrations/ is deleted while the checksum file stays, this is noticed. If both are deleted together, if a migration is changed by hand together with its line, or if the newest migration is removed together with its line, the gate does not see it; this limit is stated in docs/quality/gates.md under "What the gates do not cover".
M1-01/8 2026-10-10 The recipes of the server gates explicitly abort with exit 1 after every failed step instead of relying on .SHELLFLAGS. GNU make 3.81 (macOS) does not know .SHELLFLAGS and would continue after an error.
M1-01/9 2026-10-10 Go coverage is computed over a copy of coverage.out without the lines of the packages under test/ and perf/; coverage.out itself stays unchanged. SonarQube reads coverage.out in make check and in the CI job that only runs make sonar; only this way does it see the same file in both places. Until M1-72 adds services/server/perf/** to sonar.test.inclusions (brief M1-72, scope 8), SonarQube counts the files under perf/ without _test.go (such as perf/internal/report from M1-16 on) as production code.
M1-01/10 2026-10-10 go_prereqs.py reports every error while reading the lock file or while first loading a pin (network error, aborted download, broken lock file) as a problem of the gate (<gate>: <tool>: <error>). An abort with a traceback does not say which tool is missing and loses the problems already collected.
M1-01/11 2026-10-10 tools/conftest.py (new, outside the file list of the brief) removes, for every test, the variables with which Git hooks point to a repository (GIT_DIR and related ones); codegen_check.py does not pass them on to git ls-files. The pre-push hook calls make check; from a linked worktree pytest inherits an absolute GIT_DIR, and the Git fixtures of the tests would have reconfigured the real repository and created a commit that deletes all files. The error class was already present in older Git fixtures under tools/; it is fixed in one place, and tools/ci/tests/test_git_env_isolation.py holds that.

M1-02 Action catalogue

No. Date Ruling Reason
M1-02/1 2026-10-10 The lists of known actions in docs/api/redocly.yaml (authz_actions, receive_actions) contain no system actions. 05, test 7: no route and no op names a system action, and no event has one as its receive action; the lint enforces what a route test would otherwise only find later.
M1-02/2 2026-10-10 The table "Scopes for apps" (05, 2.6) and the configuration values of 05 stay maintained by hand; catalog-check compares them with docs/spec/actions.yaml. Their rows contain prose, and the configuration table feeds the configuration reference.
M1-02/3 2026-10-10 docs/spec/actions.yaml carries no mapping of actions to modules. The mapping belongs in the module register (ADR 0050, M1-06); one fact, one source.
M1-02/4 2026-10-10 The list "Effect" (05, 2.7) stays prose. The check derives effect per action from it (suffixes .read and .list, explicitly named IDs, exceptions after "except") and compares; an unknown ID named there is a finding. The list is a closed rule in words that sets new actions to write (fail closed); this way it stays readable, and a deviation between rule and file shows up in the gate.
M1-02/5 2026-10-10 channel_ops follows all rows of the table "Terminal channels" in 05, including one error row each for the stream of the device and for the data connection of the host, which the summary in the brief did not name. 05 is normative, and every row stays as it is (P-11); asyncapi.yaml has the same operations (terminal.send.error, hostData.send.error), and the schema expresses the rows without a change of text.
M1-02/6 2026-10-10 resource_version is required for every event type, durable and ephemeral; the brief requires it only for durable ones, and requiring it for all is a deliberate tightening. It is true exactly when the table of the resource in 03 has a column version and the event reports its new value. It is false for the exceptions from 04 "Delivery guarantee" item 3, all resources without a column version (among others user, file, instance, grant, group, principal, auth_session, gdpr_request, import_source, interaction), draft.deleted and the ephemeral events except run.updated; run.updated is true. Rule of the core contract: 04 "Envelope" gives all events the same envelope, and "Delivery guarantee" item 3 says when resource.version is set; without a required field, a silent default would decide. A draft is removed when it is deleted, so there is no new value; settings.updated, notification_pref.updated and quiet_hours.updated have the resource user without a column version and carry the version of their sub-object in the payload, like read_state.updated; run.updated reports the new runs.version (04 "Live box streaming"), the other ephemeral events report no new version of their object.
M1-02/7 2026-10-10 make catalog writes nothing while make catalog-check reports a finding other than an outdated block content (invalid catalogue, contradiction with a hand-maintained part of 05, missing, unknown or broken markers, blocks in 05 out of the order of the catalogue or outside their section, deviations next to the markers); it only replaces outdated block contents. No block is generated from an invalid catalogue, and what a generation cannot fix must be decided first.
M1-02/8 2026-10-10 Beyond the rules of the brief, catalog-check checks: order of the actions by sections, durable before ephemeral event types, the receive cell starts with self exactly when audience: self, action_text names its actions, also_actions and per_event_receive_action only with action_text, cells without a line break, without an unescaped \| and without spaces at the edge, right names system only as exactly system, no duplicate keys in a YAML mapping. Machine value and literal text must not drift apart, a broken cell would break the table in 05, system would silently become a normal action, and PyYAML silently keeps the last of duplicate keys (yaml/pyyaml#41, retrieved 2026-10-11); every check has a test.
M1-02/9 2026-10-10 The relational rules (uniqueness, sections, classes, events, ops) run only on a schema without findings. On a broken schema they mostly just repeat its findings.
M1-02/10 2026-10-10 receivers is in the file only for agent and agent_or_app; if it is missing for audience: self, principal applies. The common case needs no line; the loader sets the default.
M1-02/11 2026-10-10 tools/catalog/tests is in the pythonpath of pytest, like sonarqube/tests. This way the test helper table_import can be imported without a package structure.
M1-02/12 2026-10-10 ADR 0049 now also names the hand-maintained list "Effect" (2.7) and the attribute resource_version per event type (04 "Delivery guarantee"), and its section "More Information" refers to the row "machine-readable action catalogue" in 02, section 20, instead of quoting its old wording; otherwise it stays unchanged. In the first two places the implementation deviates from the text of the ADR; this ticket reworded the row in 02, so the quote would otherwise be wrong.
M1-02/13 2026-10-11 Next to the blocks, fail closed applies, and tests against the real artifacts hold the class. Static: chapter 05 starts with its # heading, outside the blocks every heading has a visible title (a letter or a digit from an allow-list) made only of characters from an allow-list, the generated blocks appear in the order of the catalogue (in redocly.yaml the order of the rules is free, it does not change the lint), every table is in its level-2 section and every action table under the heading of its catalogue section, the static view reads headings by an approximation of the block rules of Python-Markdown (ATX only with # in the first column, Setext only under a line that starts a block; each block rule only on the form of a single line, so that among other things containers such as quotes, lists and definition lists, the interplay of several block rules in one block, block rules across lines and markup in the title are seen only by the render test), none of the characters that the check and Markdown read differently is present (line separators only for str.splitlines, STX and ETX, which Markdown removes), and outside the generated blocks there is no HTML, no heading without a space after #, no brace pair outside a code span (code spans and escapes per line like the docs site, that is like find_code_spans from pymdownx.inlinehilite in the version from tools/uv.lock; table rows and spans across several lines are seen only by the render test), no admonition, no collapsible block and no footnote, and in no section a line with \| or <table, not even in a code block, except in the hand-maintained sections, whose titles each occur exactly once. docs/api/redocly.yaml stays within an allow-list (at the top only extends with exactly [recommended], apis and rules; per API only root and rules; roots relative and unique after resolution; no key with $; no duplicate keys), the lint rules of the catalogue keep their generated form, no rule set under apis names them, and docs/api/.redocly.lint-ignore.yaml names none. Against the artifacts: a test renders chapter 05 with the Markdown configuration from mkdocs.yml (no page metadata according to mkdocs, the same sequence of headings as the static view, only elements and attributes from an allow-list, no raw HTML except the markers according to the renderer, outside the hand-maintained sections exactly the generated tables, each in the section and under the heading of its block, no heading without a visible title, with a character outside the allow-list or in a list item or quote, and in 2.6, 2.7 and the configuration table exactly what the check reads there, which statically requires exactly one table each with a fixed header row and separator row or only list items with - in the first column without a heading, no second heading with the same letters as the title of the section (without digits, I equal to l), and lets the section end like Markdown at every line with at most as many #); a test compares the static view of code spans and escapes line by line with the renderer; an end-to-end test sets every catalogue property of the contracts at every place to an excluded value and requires exactly as many errors from the real make api-lint; the recipes of make api-lint, make catalog and make catalog-check are pinned, a direct Redocly run checks the unchanged contracts without make, and make catalog-check must fail on a copy with an outdated block. The red team and the review repeatedly found the same error class: the lint no longer works as generated, or a hand-written table escapes generation, and the gate stays green; every static list of forms left another form open. Following the rule on a recurring error class, tests against the real artifacts therefore hold the class; the static checks give fast feedback. What no self-test can cover (Makefile contents outside the pinned recipes in interaction with other files, Git state, CI, settings in mkdocs.yml other than markdown_extensions, deliberate changes to tools/catalog and its tests) is listed in tools/catalog/README.md and belongs to the review and the red team gate; M1-03 brings the direct comparison of the contracts.
M1-02/14 2026-10-11 No test of tools/catalog measures wall-clock time. A test without a clock (a text that counts the characters read) holds that the line numbers of findings about foreign line separators come from a running counter; tests over the process time of the single scan hold that code spans and headings read long lines linearly, with a limit far above linear reading and far below the earlier quadratic or cubic reading of the same line. The gates run on any machine and under load (PLAN P-07, docs/quality/gates.md); a wall-clock limit around a whole check() failed at random on efficiency cores and under load (Red Team M1-02, finding 1). Process time does not depend on the load, and the margin on both sides covers slower cores.
M1-02/15 2026-10-11 A cell of a generated table must stay exactly one cell on the site; the cell rule of M1-02/8 ("without an unescaped \|") applies in this form. To that end, the rule schema mimics how the extension tables of Python-Markdown splits a row: \\ is an escaped backslash, \| an escaped \|, a backslash before backticks escapes one of them, every backtick run pairs with the next later run of the same length, and only a \| outside a pair splits. A cell passes if every run that it opens closes in it and no \| is outside a pair; otherwise a finding line names entry, field and column. A test compares the rule with the renderer for all cells of up to _MAX_BUILDING_BLOCKS building blocks (a constant of the tests), a test renders every generated table and compares it cell by cell with the catalogue (chapter_table_cells). Red Team M1-02, finding 2: an open backtick and \\\| broke the table in 05, and the gate stayed green because the static rule looked back only one character and knew no code spans. It is the same error class as in M1-02/13 (the static view deviates from the renderer); the comparison with the real renderer holds it, the static rule gives the fast finding line. A \| in a code span now passes, because the site shows it that way.
M1-02/16 2026-10-11 The findings of catalog-check come in stages, and a stage runs only if the stages before it found nothing: reading and parsing, duplicate keys and recursive aliases, schema, relational rules (M1-02/9), then all rules on chapter 05 and redocly.yaml together. Every finding is one line, including a parse error, a value that Python cannot build (invalid date, number above the digit limit) and nesting that is too deep; the check reads aliases once per node. make catalog writes chapter 05 and redocly.yaml together or neither: a file that cannot be written stops it before the first write, and if a replacement fails, the file already replaced gets its old text back. Red Team M1-02, findings 3 to 7: a recursive alias, an invalid date or a huge number aborted the check with a traceback, nested aliases grew exponentially, a parse error spanned four lines, $ let an op name with a line break through, and a read-only redocly.yaml left 05 generated and redocly.yaml old. Stages, because later rules on a broken state mostly just repeat earlier findings; the README and docstrings name them so that new findings after a fix do not come as a surprise.
M1-02/17 2026-10-11 ADR 0049, section "Confirmation", now names what make catalog-check reads (actions.yaml, chapter 05, redocly.yaml, the ignore file of Redocly); make codegen-check keeps the generated constants in kernel/authorize and the route table in api/routes current (M1-25, M1-17). The README and docstrings no longer name version numbers of dependencies, but the version from tools/uv.lock. Red Team M1-02, findings 16 and 17: check() reads only these files, and a number in the docs that no test holds silently goes stale at the next update.
M1-02/18 2026-10-11 Two titles look the same if they show the same letters, either without digits or read with 1 as l and 0 as o (addendum to M1-02/13). Between the heading under which a generated table stands and the table there is no heading of level 4 or deeper; checked statically and on the rendered page. Red Team M1-02, findings 10 and 11: a copy of "Configuration values of this chapter" with 1 instead of l passed both layers, and a level-4 heading with the title of another section directly above an action table showed a reader a foreign title. Chapter 05 has no level-4 heading.
M1-02/19 2026-10-11 The cell rule of the rule schema (M1-02/15) also rejects HTML (< before a letter, !, ? or /) and brace pairs {...} outside the code spans of a cell, read like the lines of 05 outside the blocks; make catalog-check reports this in one line, and make catalog writes nothing. The sentence in tools/catalog/README.md that every cell stays exactly one cell on the site remains and names the two cases. Red Team M1-02 (second round), Minor 1: raw HTML or an attribute list in a cell passed catalog-check, only the render tests in make test failed, and the README promised more for catalog-check than the rule checked. Rejecting instead of narrowing the sentence, because the rule fails closed: no text in actions.yaml needs HTML or a brace pair outside a code span, a path like {session_id} is in one and passes. The rule is stricter than the renderer (even <b>, which does not split the cell, is a finding); the cell-by-cell comparison with the renderer still holds the rest of the class.
M1-02/20 2026-10-11 A profiling timer (signal.setitimer with ITIMER_PROF) enforces the process-time limit of the tests of tools/catalog (M1-02/14): it counts process time like time.process_time and stops the measured work at the limit with a test failure. Red Team M1-02, Minor 2: without the memo list of the aliases, the two alias tests ran for minutes instead of turning red; a limit that measures only after the end reports a regression as a hanging run. Process time instead of wall-clock time, because the gates run under load (P-07); the test failure comes from the signal handler as pytest.fail, so that no except Exception in the checked code swallows it. signal.setitimer exists on Unix, so on Linux and macOS, in CI and under WSL (CONTRIBUTING; Python signal.setitimer, "Availability: Unix", retrieved 2026-10-11).
M1-02/21 2026-10-11 make catalog cleans up on every exception, not only on OSError: files already replaced get their old text back, no temporary file remains, and an exception other than OSError (for example an abort with Ctrl-C) reaches the caller unchanged, with a note per file whose old text did not come back. A write error is CatalogWriteError, a subclass of CatalogError; the last line then reads cannot write the generated blocks; nothing written or, if a file kept its new text, names these files, instead of reporting catalogue or blocks invalid. Red Team M1-02, Minors 3 and 4: an abort between writing and replacing left a temporary file in docs/spec, and a write error with a valid catalogue reported an invalid catalogue. Write back on an abort too, because make catalog writes both files together or neither (M1-02/16); the subclass leaves callers that catch CatalogError (M1-03, M1-06) unchanged.
M1-02/22 2026-10-11 If several headings look like the title of a read section (M1-02/18) but not all carry exactly this title, the finding names each with its line and says that they look the same; appears <n> times stays only for literally equal titles. Red Team M1-02, Minor 6: ### 9.1 Scopes for apps 2 gave section 'Scopes for apps' appears 2 times, although the title appears literally only once. The rule stays; the finding now shows which headings are meant.
M1-02/23 2026-10-11 Ported onto the English main, the catalogue reads chapter 05 in English: section titles, header rows, cell words (yes, no, denials, conditional, fresh, as target action), directions and the word except in 2.7 follow the translated chapter, and docs/spec/actions.yaml is derived again from the translated chapter with the ticket's own importer; every machine field (IDs, classes, sections, flags, receive actions, ops) is unchanged, only the verbatim cell texts are English. The frozen copy for the migration proof is the translated chapter before the markers. Title characters are ASCII letters and digits only. All repository content is English (D-W-94); a catalogue that kept the German texts would generate German tables into an English chapter, and the language gate rejects German text.

M1-03 API contract checks

The rulings on x-problem-codes and x-status, on x-idempotency-exempt and on x-deprecated-at are R-API-23 to R-API-25 in 06-api.md, section 19.

No. Date Ruling Reason
M1-03/1 2026-10-11 The close codes in asyncapi.yaml are compared with 04 "Close codes" only by code and name; client_action is not. Ruling L5: the texts in asyncapi.yaml are written for the API, and the meaning stays in 04.
M1-03/2 2026-10-11 Problem codes that the router sends without a documenting operation carry router: true in x-problem-codes; only they may stand in no response component. Otherwise the check "every code is named by a response" would fail on method_not_allowed.
M1-03/3 2026-10-11 api.deprecation_min_notice is read in the section "Named values of all chapters" of the configuration reference through the columns Key and Default, not through the first cell; a duration may carry any unit from section 2 of the configuration reference (ms, s, m, h, d), not only s, m, h, d as in the brief, with at most six digits. M1-08 may add columns, also in front of Key; a valid value of the configuration reference must not break the gate. The configuration reference has many tables with these columns; the process configuration is in this section, and sections 6 and 7 are other sources.
M1-03/4 2026-10-11 make api-tables writes nothing while an input is unreadable or malformed, a value cannot become a table cell (missing, no text, with \|, a line break or a backtick in a code span, an operation without a declared tag) or a marker is broken, unknown or missing. Differences found by the rule api-catalogue-sync do not stop the generation. A table from a broken source would silently lose rows; the gate reports the differences between two valid sources independently of the table.
M1-03/5 2026-10-11 Beyond the sub-checks of the brief, api-catalogue-sync checks: duplicate YAML keys, values that PyYAML cannot build, wrong or empty containers and path entries that point only through $ref to components.pathItems, as the finding source; x-authz-action as an object or a list; unresolvable references to channels and messages; exactly one operation per channel, op and direction also at the terminal channels; event messages also where they stand directly in a channel, and each only once; x-receive-action or x-audience without x-event-kind; x-idempotency-exempt (also with an empty value) at a path entry, another method or a webhook; the header Idempotency-Key case-insensitively and only as a header (also behind $ref); a closed list of keys per entry in x-problem-codes and texts without German umlauts or sharp s. Example values (example, examples) do not count for the deprecation, names under properties are no fields, and recursive YAML aliases do not loop. api-tables also reports missing tags and an operation with an unresolvable channel reference. Copies of catalogue values in the schemas are checks of their own: AppScope equal to the scopes of the catalogue (sub-check app-scopes), the union Event equal to the durable types, the type enum of every event schema equal to its type (also for the webhooks), the op enum of every frame equal to its op, and on the terminal channels a close code below 4100 only with its name from 04. PyYAML keeps the last value of duplicate keys silently, the Redocly enumeration skips objects, an operation behind a $ref or in a channel would otherwise meet no check, and each of these gaps would let a wrong contract stay green or a row drop silently out of a table; a broken input gives findings, never a traceback: every sub-check and every block runs protected, and an unexpected error becomes its finding (input not checkable, input not renderable), because the same error class appeared a second time after the final review. Every check has a test.
M1-03/6 2026-10-11 Differences found: file_blocked stood in no response component; Conflict now names it (source completed). dispatch in asyncapi.yaml did not follow the order of the catalogue (host.updated and terminal_session.changed after the ephemeral events, the ephemeral ones among themselves in another order); the list now follows it (source reordered, asyncapi.yaml 0.1.1). The block idempotency_exempt in 06 did not name the five host and terminal operations that openapi.yaml exempted with auth_flow: pairHost (redeems a code without a login), openTerminalSession (consumes a fresh step-up) and attachTerminalSession (makes older session tokens worthless) stay auth_flow, and the copy now names them; createHost and issueHostPairingCode now carry the parameter IdempotencyKey and name the response 422 (idempotency_key_reuse), and a replay answers 409 conflict with reason: one_time_secret (source corrected, openapi.yaml 0.2.0). The enum AppScope did not name costs:reconcile, although the catalogue assigns it to the action cost.reconcile.submit (05 section 2.6, ADR 0055); it now contains it (copy completed). Like 05, the table in 06 put the durable before the ephemeral events, but the last four ephemeral ones stood in another order than in the catalogue; now list and table follow the order of the catalogue and of 05 completely. The order of the list in asyncapi.yaml carries no meaning (PATCH). createHost and issueHostPairingCode hand out a pairing code once, logged in and with an audit entry (24, sections 3.2 and 3.3); for this, 06 section 7 explicitly names Idempotency-Key with one_time_secret ("POST with a one-time secret in the response", R-API-26), as for createEnrollmentCode, and the brief allows an exemption only where a reason clearly fits. By the table in 06 section 13, changing the idempotency rule of an op is a breaking change; it is acceptable here because 0.x applies before the first publication, hence MINOR.
M1-03/7 2026-10-11 No call of api_checks.py ends in a traceback, and one place closes the class: main turns every error that no protected part reports into a finding line <rule>: source: input not checkable (...) (for generate: input not renderable) with exit 1. The protected parts still give precise findings: every sub-check, every block and now also the form check of every API file (finding source of the file). generate writes every file it changes to a temporary file next to it first and replaces the targets only when all are complete; a file that cannot be opened for writing or written is a finding, and no file is changed. Only if the rename itself fails does the message name the files already replaced instead of "nothing written". A table cell is only what str.splitlines reads as one line, so nothing with U+2028 and related characters either. The red team of the first round found the same error class a third time: a YAML key whose integer str() cannot print (5000 hex digits; Python limits the conversion of large integers to strings, integer string conversion length limitation, retrieved 2026-10-12) ended in a traceback in the form check, as did a read-only target file in generate (if it was the second one, the first was already new), and a cell with U+2028 and marker text broke a marker in generate. Writing in place left a half-written file behind on a write error in the middle of the file (full disk, size limit); the final review of this round found that. By the rule on recurring error classes, one place now catches it, tested against the real files; the marker functions of tools/catalog split the text with str.splitlines, so the same line rule applies to the cells as there (M1-02).
M1-03/8 2026-10-11 contentType: application/octet-stream exempts a frame from the check of the op enum only on a terminal channel and only if it names no op; at the gateway never. The gateway knows only JSON frames (04 section 2.3); only the Noise frame of the terminal channels is binary. The earlier exemption let every frame marked as binary pass, also a gateway frame with a wrong op (regression, red team of the first round).
M1-03/9 2026-10-11 A message that stands inline instead of through $ref in messages of an AsyncAPI operation (also with $ref: null) is a finding of the sub-check ws-ops or channel-ops, at the gateway also of the block ws_ops (make api-tables then writes nothing); it does not also run through the sub-check events. AsyncAPI 3 requires $ref pointers to the messages of the channel there (AsyncAPI 3.0.0, Operation Object, retrieved 2026-10-12). A copy written inline in dispatch ran past events into the table (red team of the first round). The rule in one place, when reading the operations, covers the gateway and the terminal channels and both rules, and is cheaper than a second check of the copy.
M1-03/10 2026-10-12 Ported onto the English main: the generated tables of 06 and webhooks.md are rendered in English, equal to main's translated blocks; the tool reads 04 "Close codes" and the configuration columns Key and Default; the check for German letters in problem texts builds its character class from code points; README, rulings and test data are English. The port's regenerated block idempotency_exempt also lists renewInvocationToken (agent_runtime), which openapi.yaml exempts and main's copy did not list. Main's Conflict already named file_blocked (at the end of its list) and main's AppScope already listed costs:reconcile; the port keeps one entry of file_blocked, in the place the ticket gave it, and leaves AppScope unchanged. All repository content is English (D-W-94); the generated blocks must equal what main shows, and the gate G3 that this ticket brings found the missing row.

M1-04 Operations configurations in the gate

No. Date Ruling Reason
M1-04/1 2026-10-11 The validators run as pinned release binaries through tools/bin/fetch_tools.py, not as container images. The same way as every other gate tool, no Docker for a pure docs change, a checksum per platform.
M1-04/2 2026-10-11 fetch_tools.py reads zip archives (recognized by the signature PK\x03\x04; the member must exist and be a regular file). Loki publishes only zip assets (besides rpm and deb packages) for its binaries (Loki v3.7.8 release, retrieved 2026-10-12).
M1-04/3 2026-10-11 Pairs of collector overlays are checked in both orders; none stands alone in the check only. Overlays replace lists when merging (by default the value of the last configuration source is used, confmap README, retrieved 2026-10-12), so the result depends on the order of BOTHY_OTEL_EXPORTER and BOTHY_OTEL_EXPORTER_2; none is empty and changes no pair.
M1-04/4 2026-10-11 The pins of validators whose component also runs as an image (otelcol-contrib, promtool, amtool, loki, tempo) follow the image versions: Renovate raises the images (group "Compose-Images", ADR 0041), is off for these validators, and the check tool-versions keeps both equal. A newer or older validator accepts other keys or rejects others than the running component; the images need an update path, and one pull request per raise keeps image and validator together.
M1-04/5 2026-10-11 The routing of Alertmanager is part of the gate (amtool config routes test for page, ticket and Watchdog). alerting.md named the routes as checked by hand; a wrong route silences page alerts.
M1-04/6 2026-10-11 The check collector sets the host name validate.example.com in its working copy of exporters/datadog.yaml; the overlay in the repository stays unchanged. Otherwise validate queries metadata endpoints of clouds (network) and waits about 25 seconds: without hostname the exporter determines the host name automatically and waits up to hostname_detection_timeout, default 25s (example configuration of the exporter, lines 166 to 174 and 451 to 455, retrieved 2026-10-12); operation keeps the automatic host name detection. The exporter queries the GCP metadata even with a fixed host name; M1-04/22 stops that.
M1-04/7 2026-10-11 Every validator runs with exactly PATH, HOME and TMPDIR in the working folder of its check, PATH is an empty folder, plus HTTP_PROXY and HTTPS_PROXY (also in lower case) on a closed port of 127.0.0.1 (NO_NETWORK_PROXY) and only the variables the check names. Neither the shell of the developer nor a program from her search path can change the result; all seven validators run without other programs (checked locally on 2026-10-11). The proxy stops HTTP downloads that use it: the final review found that tofu init downloads module sources from a registry from the network despite the empty provider mirror; with the proxy, such a download fails at once and becomes a finding. Clients with their own transport bypass it; M1-04/22 covers that.
M1-04/8 2026-10-11 The validators always come from the tool chain of the checkout in which opscheck.py lies (tools/bin/tools.lock.json, cache .cache/tools/), regardless of --root; the check tool-versions, however, reads the lock file under --root. --root names the checked files, not the tool chain; the tests check copies under tmp_path with the real pins without downloading them again.
M1-04/9 2026-10-11 The check tofu gives tofu an empty local mirror as its only provider source (provider_installation { filesystem_mirror }) instead of an empty CLI configuration (OpenTofu CLI configuration, provider installation, retrieved 2026-10-12). With an empty configuration, tofu init would have downloaded providers from the registry, a network call the brief forbids; this way a module with providers fails, as the brief requires for ops-check, until a pinned way without network is defined. The proxy from M1-04/7 stops module sources from a registry or HTTP, the empty PATH stops Git sources.
M1-04/10 2026-10-11 BothyMalwareScanFailing reads ... > 0 unless sum(rate(bothy_file_scan_total{bothy_outcome=~"clean\|infected"}[15m])) > 0 instead of ... > 0 and sum(...) == 0. The new unit test showed: without a series clean or infected (the scanner has never worked since the start), the sum is empty instead of 0, and and suppressed the alert exactly in this case (vector1 and vector2 keeps only the elements of vector1 with matching elements in vector2, PromQL logical operators, retrieved 2026-10-12).
M1-04/11 2026-10-11 The firing cases of the alert unit tests lie just above the threshold (a small step past it) and check the alert at the first evaluation at which it may fire: in the minute its for runs out or, for an alert without for, in the first minute its condition holds, with a quiet case one minute earlier (Watchdog fires from the first evaluation on and has no earlier case). Corrected by M1-04-minors: before, the claim "quiet one minute earlier" also stood for the alerts without for, whose firing cases came one minute late. Locally on 2026-10-11: every threshold of bothy-alerts.yaml moved one percent up or down (for threshold 0 to 1 and -0.5) and every for removed, made one minute longer or shorter broke at least one case, 71 of 71 mutations caught. The red team on d73c418 found that an added for: 1m on the five alerts without for passed every case. test_alert_unit_tests_hold_every_for_duration now adds a for to every alert without one and makes every other for one minute longer and shorter (removes it at one minute), and expects a broken case of that alert for each.
M1-04/12 2026-10-11 BOTHY_TAILSCALE_VERSION in infra/compose/.env.example carries the Renovate comment too; the group "Compose-Images" selects by files, not by manager, and switches off the digest pins for these two files. The regex manager for compose.yaml reads every default ${BOTHY_<X>_VERSION:-...}, so also the one of Tailscale (package rules select by matchFileNames, Renovate configuration options, retrieved 2026-10-12); without the comment, an update would have changed only one of the two files. The variables carry the version tags that operators read and set; a digest pin could not be written without currentDigest in the pattern.
M1-04/13 2026-10-11 markdown_table finds a heading by its title without #, without a section number and without a parenthesis at the end ("Further alerts" for "3.2 Further alerts (bothy-alerts.yaml)"); headings and tables in code blocks do not count. A new numbering of the sections does not break the checks; an example in a code block is never read as a source.
M1-04/14 2026-10-11 tools/opscheck/tests/test_opscheck.py loads opscheck.py explicitly from its file. The import mode importlib of pytest (pytest import modes, retrieved 2026-10-12) names the test module opscheck.tests.test_opscheck and creates the folder tools/opscheck as the package opscheck; a plain import opscheck would find this empty package instead of the tool. The brief prescribes path and name of the tool.
M1-04/15 2026-10-11 infra/README.md, infra/tofu/README.md, the ADRs 0012, 0034, 0035 and 0058 and docs/observability/dashboards.md stay unchanged, although they name checks that ops-check now runs. The brief allows changes only to the named files; these places describe earlier manual checks or the confirmation of an ADR and are still correct.
M1-04/16 2026-10-11 slo-docs writes the target with one decimal and with more only where one decimal would round the target (99.95 %). The rule of the brief ("one decimal") would have required the wrong row 100.0 % for a target such as 99.95 and missed a difference in the second decimal; for all current targets the result is the same.
M1-04/17 2026-10-11 prometheus-config also requires: every entry of rule_files lies under /etc/prometheus/ (a relative entry counts, as in Prometheus, from the folder of prometheus.yaml) and hits at least one file, and every file in rules/ is loaded. rule_files takes file globs (Prometheus configuration, retrieved 2026-10-12), and promtool check config accepts a pattern without hits (measured with the pinned promtool, the test of this rule holds it); a typo in the pattern would have let Prometheus start without a single rule, page alerts included, and the gate would have stayed green.
M1-04/18 2026-10-11 alert-thresholds reads the right-hand side of the last > comparison without PromQL comments and strings up to ), and, or, unless or the end; if it is not a plain number, that is a finding. A comment (# before > 1), a > in a label value or arithmetic (> 93600 * 2) would otherwise have given a wrong number, in the last case even a false success.
M1-04/19 2026-10-11 tofu copies infra/tofu/ once as a whole and checks every module in this copy. A module with a sibling as its source (../common) would otherwise have failed depending on the alphabetical order of the names.
M1-04/20 2026-10-11 Code blocks in Markdown end, as in CommonMark, only with a line of the same character in at least the same length, also indented in a list (CommonMark 0.31.2, fenced code blocks, retrieved 2026-10-12); tool-versions reads .env.example as Compose does (quotation marks and a comment after a space and # do not count, also together; Compose .env file syntax, retrieved 2026-10-12); --only may appear several times; an empty exporters/ or slo/ is a finding. Findings of the final review: a mixed code block, a quoted value or a repeated --only would have given wrong results, and an empty directory would have checked nothing and still reported ok.
M1-04/21 2026-10-11 The header text of renovate.json names the exception of the Compose images from the digest pins. The group "Compose-Images" switches them off (M1-04/12); otherwise the header text promised digest pins for all images.
M1-04/22 2026-10-11 Every validator gets GCE_METADATA_HOST on the same closed port of 127.0.0.1 as the proxy. tools/opscheck/tests/test_opscheck.py::test_no_validator_reaches_the_network runs all checks with the real pins in a sandbox that ends every validator on a connection beyond loopback; the test runs only on macOS (sandbox-exec) and is skipped otherwise. The red team found connections on port 80 to a fixed address in every run with the Datadog overlay, past the proxy. Narrowed down locally: only the Datadog exporter (not the connector) probes, also with a fixed host name and host_metadata.enabled: false; AWS_EC2_METADATA_DISABLED changes nothing, GCE_METADATA_HOST ends it (before, 30 of 30 runs blocked, after, 0 of 90). The GCP metadata library has its own transport without a proxy (its default client builds an http.Transport without a Proxy function, metadata.go, retrieved 2026-10-12). On Linux there is no lock that makes an attempt visible (unshare -n only prevents it silently); docs/quality/gates.md states this limit.
M1-04/23 2026-10-11 If a cell of the column "Configuration value" in alerting.md names a key of the configuration reference, it must read exactly `<name>` or above `<name>` (a no-break space after "above" counts as a space); any other form is a finding. Finding of the red team: a reformatted cell (`backup.max_age` (26 h), a key without backticks) would have silently dissolved the coupling of threshold and value.
M1-04/24 2026-10-11 An alert counts as tested in prometheus-rule-tests only when a case sees it fire (non-empty exp_alerts). Finding of the red team: an alert with only silent cases counted as tested, although no case shows that it fires at all.
M1-04/25 2026-10-11 collector-allowlist also checks that the allowlist works: allow_all_keys is false, every trace and log pipeline (base and overlays) with a receiver that is no connector runs redaction/allowlist, and no overlay redefines the processor or a pipeline of the base. Finding of the red team: allow_all_keys: true, a processor removed from traces/in or an overlay that overwrites it passed both collector checks. The canary tests (PLAN M1-69, M1-73) would see the effect only later.
M1-04/26 2026-10-11 slo-docs derives the column Alert from page_alert.disable and ticket_alert.disable: Page, Ticket, Ticket only, Page only or none. It reads the le bound in each PromQL quoting (le="x", le='x', le=`x`, spaces around = allowed); a regex or negated le matcher (=~, !=, !~) is a finding, and then the column Threshold of that row is not compared. Red team Minors 2 and 3 on d73c418: with the ticket alert disabled the correct row failed and the wrong row Page, Ticket passed, and le='0.5' read as no bound. A regex or negated matcher names no single bound, so the row cannot be checked; one finding says why instead of a second, misleading one about the cell. The cell values follow the table they are compared with.
M1-04/27 2026-10-11 tofu counts a directory under infra/tofu/modules/ as a module when it holds .tf, .tf.json, .tofu or .tofu.json files (TOFU_FILES). OpenTofu reads all four forms (OpenTofu files and directories, retrieved 2026-10-12); a module made only of .tofu files was skipped and the check said ok (red team Minor 4).
M1-04/28 2026-10-11 alertmanager maps the container path: amtool runs on a work copy of infra/observability/alertmanager/ whose /etc/alertmanager/ prefixes point into the copy, so check-config parses the templates; every templates entry must lie under /etc/alertmanager/ (a relative entry counts from the directory of alertmanager.yaml) and match a file, else it is a finding. A file that is no YAML skips only this entry check; amtool reports it, and the route tests still run. Of the two options of the brief (map the path, or fail on an unmapped one) the mapping is taken, with the loud failure for every path outside the mount. Red team Minor 5: a broken template named by its container path passed. Compose mounts exactly that directory there, so the mapping checks the normal form instead of forbidding it, and it is the cheaper option: it reuses the rule_files rule of prometheus-config (M1-04/17), now one helper _mounted_files for both. amtool accepts a pattern without a match, as promtool does.
M1-04/29 2026-10-11 collector reports a reference to a config provider other than env, file and yaml (COLLECTOR_PROVIDERS; for example ${https:...}, ${s3:...}) and then runs no validate. Every opening ${<provider>: counts, also one inside another reference (${env:NAME:-${https:...}}, which the collector resolves from the inside out); only YAML strings count, keys and values, not comments, and a file PyYAML cannot read is searched as text; $$ is the escaped $. The finding says the provider is not allowed, because not every other provider reaches the network (aes decrypts locally with a key from the environment, aesprovider README, retrieved 2026-10-12). The docstring of NO_NETWORK_PROXY and tools/opscheck/README.md now say that the proxy stops only clients that honour the proxy variables. Red team Minor 8: in a sandbox ${https:...} tried a connection past the closed proxy, so the old text claimed more than the proxy does. The collector resolves references while it validates, so only a rule before validate keeps the gate off the network. A comment is no reference. The closing review of these fixes found the nested form and the file PyYAML rejects but the collector reads (both passed the first version). The Google Cloud Storage module source of tofu (gcs::) is not stopped by the proxy (red team run: it waited until COMMAND_TIMEOUT_S); an s3:: source failed at the closed proxy in the closing review. The README says so; a rule for gcs:: is not part of this follow-up.
M1-04/30 2026-10-11 main handles SIGINT and SIGTERM alike for the run: the handler kills every running validator by its own process id, no validator starts after the stop (start and registration share one lock with the stop), run_checks removes its work directory, the exit status is 128 plus the signal number with the line opscheck: stopped by <SIGNAL> on stderr, and the previous handlers are restored afterwards (SIG_DFL where the previous handler was not set from Python). A killed validator is always waited for, and after a timeout its output is not read on, as subprocess.run did (the child is killed and waited for, subprocess.run, retrieved 2026-10-12). After SIGINT the script itself ends by SIGINT once it has cleaned up, as Python does after an uncaught KeyboardInterrupt (What's New in Python 3.8, retrieved 2026-10-12), so a calling shell stops too; a stop that arrives during the removal of the work directory finishes the removal first. Red team Minor 9: after SIGTERM the work directory opscheck-* stayed and a tofu init kept running. The red test showed a second gap: with SIGINT the thread pool of collector waited for its running validators, so the run did not stop within 20 s. The lock is reentrant and a stop that arrives while the main thread starts a process raises only after that process is registered and killed, so none escapes.
M1-04/31 2026-10-12 Ported onto the English main: the tool reads the translated tables ("Further alerts", "Levels and routes", "Attributes in spans and logs", the columns Alert, Level, Configuration value, Key, Default, Attribute, the SLO columns Target, Threshold, Alert with the cells none, Ticket only and Page only), the threshold form is above `<name>`, the alert unit tests expect main's English annotation texts, and README, rulings, comments and test data are English. All repository content is English (D-W-94).

M1-05 Server skeleton

No. Date Ruling Reason
M1-05/1 2026-10-11 The module register follows 02 and 03 as they stand after the addendum of 2026-10-10: 20 modules including terminal, stats owns stats_minutes, kernel.core has seven packages (ids, clock, errs, textcheck, reqmeta, jose, designkeys), kernel.ports eight (with measure). Every module has a port package, terminal too. The brief requires taking the lists and modules from 02; its numbers (19 modules, three kernel core packages) date from before the addendum, which P-24 worked only into other briefs. No gap in the core contract (P-11): 03 "Modules" still lists two modules without tables (gateway, search).
M1-05/2 2026-10-11 All twelve kernel packages except ids, clock and errs consist of a doc.go with purpose, implementing module and users from 02 "Cross-cutting ports", not only the seven named in the brief. The goal of the ticket is that the cross-cutting packages live in internal/kernel; the content comes with the implementing ticket.
M1-05/3 2026-10-11 The generated .go-arch-lint.yml sets allow.ignoreNotFoundComponents: true; there are no placeholder packages for domain, app, adapters, test or perf. go-arch-lint v1.19.0 aborts for every in pattern without a directory, also for internal/<m>/adapters/**, test/** and perf/**; the fallback of the brief (doc.go in domain and app) does not cover this. With the setting, a package without a component and an unknown component name still fail, and a component with two patterns, one of which matches, stays assigned (trial run on 2026-10-11; 02 "go-arch-lint"). The error class is thus fixed in one place, and no package is created without a user. Source: 02-architecture.md, which cites the go-arch-lint v1.19.0 syntax for ignoreNotFoundComponents, retrieved 2026-10-12.
M1-05/4 2026-10-11 Error kinds of the four codes that the addendum added to 06: terminal_denied to forbidden, i6_violation to invariant_violation, host_unavailable and fingerprint_mismatch to conflict. The status is in 06; i6_violation is an invariant like i1_violation, the other two are state conflicts. TestCodesMatchChapter06 checks codes and status against the generated block in 06.
M1-05/5 2026-10-11 The go.mod files of the fixtures of tools/archgen are named go.mod.fixture; the test renames them when it copies them into a temporary directory. A tracked go.mod under services/ would be a Go module of its own for the Makefile (all server gates would run on it) and, for OSV-Scanner and the license gate, a lock file with the stub version of a driver.
M1-05/6 2026-10-11 tools/archgen outputs modules in the order of the register, platform packages and vendor groups sorted by name. The module order carries the layers from 02; for platform and vendors the order has no meaning, and a reordered map should not change the generated file.
M1-05/7 2026-10-11 Every generated Go file in this repository ends in .gen.go; sqlc writes to sqlcgen/. sonar.exclusions excludes both and **/testdata/**. .gen.go is already known as generated in sonar-project.properties and in tools/ci/license_check.py (SPDX exception); one rule instead of many patterns.
M1-05/8 2026-10-11 kernel/ids generates UUIDv7 with its own implementation following RFC 9562 (section 5.7, monotonicity per 6.2 "Monotonic Random"), without a library. The kernel uses only the standard library (02 "Cross-cutting ports"); the implementation is small and fully tested (overflow, clock running backwards, concurrency). Source: RFC 9562, sections 5.7 "UUID Version 7" and 6.2 "Monotonicity and Counters", retrieved 2026-10-12.
M1-05/9 2026-10-11 YAML library of tools/archgen: go.yaml.in/yaml/v3 v3.0.5 with Decoder.KnownFields(true) and a pass over yaml.Node for YAML paths, missing and duplicate keys. Maintained continuation of gopkg.in/yaml.v3 under MIT and Apache-2.0 (both in quality/licenses/allowed.txt); the only dependency of the module. Source: go.yaml.in/yaml/v3, retrieved 2026-10-12.
M1-05/10 2026-10-11 The data parts of the module READMEs (purpose, port, dependency line with reason, tables, actions, events) follow 02; the form of the line May use the ports of these modules: and of the section ## Tables stays the same. audit and identity (tables), sync and gateway (dependencies), stats (table, events, ops), the actions of instance, authz, identity, chat and messaging, and port texts were aligned. The READMEs name 02 as normative, and the register follows 02; without the alignment, README and register would contradict each other after this ticket. M1-06 parses the line and the section, so their form stays.
M1-05/11 2026-10-11 bothy passes context.Background() to the commands; only serve handles signals. The second signal ends serve with exit code 3 (02 "Commands of the binary", M1-11); a global signal context in main would preempt this decision.
M1-05/12 2026-10-11 The Go branches of lint, typecheck, osv and licenses in the root Makefile are corrected, although the brief allows only the recipe test: lint looks for the pin of golangci-lint in the repository root instead of after cd into the module, every module loop aborts at the first error with exit 1 (like M1-01/8), and go-licenses checks only the dependencies with --ignore <module path>. tools/ci/tests/test_make_server_gates.py passes the fake of FETCH, like the real FETCH, as a path relative to the repository and checks all four branches. The branches never ran before the first Go module: lint failed with exit code 127, go-licenses found no license for the project's own packages (the LICENSE lies above the module root services/server; tools/ci/license_check.py checks the project's own license). An empty module path aborts, because --ignore "" would leave out every package. The relative fake path covers the error class "pin search after cd" for every recipe with a fake test. Source for --ignore: go-licenses, retrieved 2026-10-12; the missing license of the own packages was observed in M1-05.
M1-05/13 2026-10-11 tools/archgen rejects entries in exclude and testsupport.paths under internal, cmd or api (for exclude refined by M1-05/18: go-arch-lint compares as a string prefix), as well as an overlap of testsupport with exclude, and vendor patterns whose first element is a wildcard (**). Tightened by M1-05/17: this was not enough, because go-arch-lint reads patterns as a regular expression ([^/]+/** and github.com/jackc/pgx/v5\| got through). TestChapter02ExamplesMatchGenerator checks the example register from 02 "Module register as the source" and the generated excerpt in 02 "go-arch-lint" against the generator. Findings of the final review: a single register line would have silently taken product code out of make arch or allowed every import, and the excerpt in 02 was outdated after the addendum (measure was missing, order of the platform packages, vendor group otel was missing in the example). The test prevents chapter and generator from drifting apart again. Source: 02-architecture.md, which cites the go-arch-lint v1.19.0 source, retrieved 2026-10-12.
M1-05/14 2026-10-11 tools/archgen puts every component first in its own mayDependOn; for this, kernel-core and every <m>-domain get a deps entry of their own (kernel-core: [kernel-core], <m>-domain: [<m>-domain]; superfluous for kernel-core, see M1-05/28). TestArchLintChapter02Layout builds the layout from 02 "Code layout" with several packages per component (adapters/postgres/sqlcgen, platform/pg/pgtest, adapters/chattest, subpackages of domain, app, wire and under cmd, api/routes calls api/rest); TestArchLintLayersStayClosed shows that no other edge opens. Red Team finding: go-arch-lint 1.19.0 does not by itself allow a component to import its own packages (only a common component under commonComponents), and make arch rejected exactly this layout. The assumption in the brief M1-05 ("without an entry a component may use only the common component" and thus itself) was wrong for the tool; the brief, as the assignment, stays unchanged, 02 section 7.2 and the comment in the generator are corrected. Source: go-arch-lint v1.19.0 builds the allowed project imports of a component only from its mayDependOn and the commonComponents (allowed_project_imports.go), retrieved 2026-10-12.
M1-05/15 2026-10-11 kernel-ports gets no self entry: a kernel port imports no other kernel port, only kernel-core. With exact paths per port, the self entry would be exactly the mutual use of the ports. 02 says nothing explicit about this (section 4.5: "kernel depends on nothing in the project"), so the narrower option applies; a ticket that needs an edge between two ports justifies it and changes the rule, 02 section 7.2 and TestArchLintLayersStayClosed in the same change. Source: go-arch-lint v1.19.0 rejects a deps entry without mayDependOn, canUse or an any... flag (validator_deps.go), retrieved 2026-10-12.
M1-05/16 2026-10-11 Every key and every string value in the register is a plain YAML string (tag !!str); tools/archgen rejects tagged keys (null, !!binary, !!int, !!bool, !!timestamp, the merge key <<) and tagged values with line, column and YAML path. Extended by M1-05/30 to plain scalars that YAML 1.1 reads differently. Red Team finding: a !!binary key appeared with its text in the node walk and base64-decoded in the typed decode, and the checks saw a different entry than the one written (second instance after null keys). A !!binary value like [!!binary Y2hhdA==] silently became chat, and other readers of the register (M1-06) would see other names. The class is fixed in one place: only !!str counts. Source: YAML binary type (!!binary is base64-encoded octets), retrieved 2026-10-12.
M1-05/17 2026-10-11 A vendor pattern in the register is a Go import path: the first element is a lowercase domain with a dot, every further element consists of letters, digits, -, _, ~ and inner dots, ** only as the last element and only after a domain and one more fixed element (github.com/** fails). TestImportPathRule, new cases in TestRegisterRejects and TestArchLintVendorPatternIsLiteral (end to end with a second library stub in the skeleton) hold this. Red Team finding: go-arch-lint 1.19.0 translates a pattern into a regular expression and escapes only . and /; github.com/jackc/pgx/v5\| and [^/]+/** allowed every third-party import (red before the fix: archgen accepted them, go-arch-lint reported "No warnings found"). Apart from the escaped dot, the characters of a Go import path have no meaning in a regular expression. A pattern whose fixed part is only a hosting domain would allow almost all libraries (Minor 18 of the Red Team); the narrower rule requires at least a domain and one further element. Source: 02-architecture.md, which cites the go-arch-lint v1.19.0 source, retrieved 2026-10-12.
M1-05/18 2026-10-11 tools/archgen checks exclude the way go-arch-lint applies it: as a string prefix. An entry fails if it is a prefix of internal, cmd or api or lies inside one of them, if a testsupport path starts with it, and, during generation, if it is a prefix of another directory next to the generated file (next to tools/, for example toolsx/). The directory check runs in go generate and thus in make codegen-check. Red Team finding: with exclude: [tools, in] and a directory in/, all of internal/ dropped out of the gate (go-arch-lint "No warnings found" despite a forbidden import), and exclude: [tools] silently took toolsx/ along. An allow list for exclude would be narrower, but would need a list of its own for every skeleton; the prefix check hits exactly the error class. Source: 02-architecture.md, which cites the go-arch-lint v1.19.0 source, retrieved 2026-10-12.
M1-05/19 2026-10-11 billing_mode_mismatch and tool_class_mismatch have the error kind invariant_violation, not conflict as in the brief M1-05; the status stays 409. TestCodeKindsFollowBriefAndRulings now names the kind of every code, TestEveryCodeConstantIsInTable checks every code constant against the table. 07 section 4.1 assigns both reasons to invariant_violation (I3 at the start of a run, tool class above the invocation token), and the brief M1-28 expects the same kind; the brief M1-05 named conflict without a reason (Minor 5 of the Red Team). Because both kinds have 409, 06 and the generated block of the problem codes stay consistent; other briefs stay unchanged.
M1-05/20 2026-10-11 (*errs.Error).PublicCode() returns the code if it has a kind, otherwise internal_error, also for a nil *Error; Error() writes this public code. Responses (problem writer from M1-18) take the code from PublicCode() and the status from Kind(), never from the field Code; LogValue still writes the raw code. Cause carries json:"-". Minors 3 and 4 of the Red Team: an *Error from a struct literal had the kind internal (500), but its text and code stayed unknown (bogus: d), and json.Marshal wrote the internal cause. Code and kind now come from the same source; in the log the raw code stays visible, so that the cause can be found.
M1-05/21 2026-10-11 A position in an error message of ids.Parse is the 0-based byte index of the faulty character, for hyphens as well as for hex digits. The message for a missing hyphen already used the 0-based index; for the second digit of a pair, the hex message named the position before it (Minor 11 of the Red Team).
M1-05/22 2026-10-11 Without the make feature oneshell (older GNU make, for example the one on macOS), the root Makefile sets SHELL := bash -euo pipefail; a make with oneshell uses SHELL := bash with .SHELLFLAGS := -euo pipefail -c unchanged. The explicit \|\| exit 1 after every step stays mandatory (M1-01/8); fmt-check and the semgrep rule tests now have it too. tools/ci/tests/test_make_shell.py checks errexit, pipefail and nounset with the make first in PATH; the fake tests still also run with bash -c without flags. Red Team finding (Major 2, Minor 33): make 3.81 ignores .SHELLFLAGS, so fmt-check ended with exit 0 on an unreadable Go file; it was the second instance after M1-01/8. make 3.81 calls $(SHELL) -c <line> and splits the SHELL value at spaces, so the flags arrive there (probe: make -p shows SHELL := bash -euo pipefail under 3.81, SHELL := bash under 4.4.1). Rejecting make 3.81 was ruled out, because the self-hosted runner and macOS use it. The class is fixed in one place; make -n check shows only the lines of fmt-check, semgrep, licenses and arch changed. Source: GNU make NEWS, version 3.82: ".SHELLFLAGS" and ".ONESHELL" are new there, retrieved 2026-10-12.
M1-05/23 2026-10-11 make licenses reads the allowlist through the variable LICENSE_ALLOWLIST (default quality/licenses/allowed.txt) and aborts with licenses: <path> lists no license if it names no license; lines of only spaces do not count. Minor 24 of the Red Team: under make 3.81 an empty list became --licenses= and --allowed_licenses=, which no longer restrict the licenses, and the gate passed; under make 4 it ended without a message. Through the variable, tests check the case without changing the real file. Sources: go-licenses v2.0.1 falls back to rejecting only forbidden and unknown licenses when no allowed license is given (check.go); osv-scanner v2.6.0 treats an empty --licenses list as no allowlist and prints only a summary (getters.go); both retrieved 2026-10-12.
M1-05/24 2026-10-11 make arch rejects every symbolic link below a Go module, directory or file, tracked or not (find . -type l in the working tree), and names every link before go-arch-lint runs; only editor lock files are exempt (M1-05/27). Red Team finding (Major 6): go-arch-lint 1.19.0 follows no linked directory and assigns it by the path of the link, while the Go compiler builds the code behind it; a link internal/chat/domain/h -> ../../../test/harness smuggled test code including a third-party library past make arch (before: rc 0, after: rc 2 under make 3.81 and 4.4.1). The working tree counts, because go-arch-lint and the compiler read it. Source: go-arch-lint v1.19.0 walks the module with filepath.WalkDir, which does not follow symbolic links, retrieved 2026-10-12.
M1-05/25 2026-10-12 Ported onto the English main: the register also takes host_session_users (module terminal) and run.token.renew (module runs) from main's chapter 02 (tables 4.2 and 4.6), and the module READMEs get only the ticket's status line, the section ## Code and its purpose lines for audit, identity and stats, because main's READMEs already carry the other alignments of M1-05/10. The ticket's documentation changes are applied in English to the translated chapters; comments and the Makefile name the English files and section titles. M1-05/1 makes 02 the source of the register; the newer main must not lose entries the ticket base did not have. All repository content is English (D-W-94).
M1-05/26 2026-10-12 The kernel port clock.Clock keeps its name although it has one method; SonarQube Cloud's rule for single-method interfaces is suppressed on that line with NOSONAR. Effective Go names one-method interfaces by the method plus -er as a convention, not a rule; Nower says less than Clock, which names what the port is and is the name its callers (kernel/ids) use. Source: Effective Go, Interface names, retrieved 2026-10-12.
M1-05/27 2026-10-11 make arch passes editor lock files: symbolic links named .#* whose target does not exist (find . -type l \( ! -name '.#*' -o -exec test -e {} \; \) -print). Every other link still fails, also a .# link with an existing target and a dangling link with another name; test_arch_passes_editor_lock_files_only and test_make_arch_passes_an_editor_lock_file hold this. Because go-arch-lint 1.19.0 aborts at every dangling link below a component with ** (failed to resolve path '.../cmd/**') before exclude or excludeFiles apply, make arch checks, when there are lock files, a copy of the module without names .#* and maps the paths of the copy in its output back to the module; without lock files go-arch-lint runs in the module as before. test_make_arch_with_editor_lock_files_and_the_real_go_arch_lint checks this with the pinned go-arch-lint, clean and with a violation. Minor 1 of the second red team: Emacs creates, for a file it edits, the dangling link .#<file> with the target <user>@<host>.<pid>:<time> ("The name is constructed by prepending .# to the file name of the buffer", target <user>@<host>.<pid>:<boot>, GNU Emacs Lisp manual, File Locks, retrieved 2026-10-12), and make arch failed on it locally. The compiler cannot build anything behind a link without a target; a .# link that resolves stays forbidden, because like every link it would bring code past go-arch-lint. The closing review and the re-review found that go-arch-lint then failed at the lock file of a Go file, and under cmd/** or internal/wire/** at every lock file; an entry in excludeFiles helped only outside **. The go tool skips files whose name starts with a dot (cmd/go, file system patterns: ... excludes "files and directories with names beginning with "_" or ".""; go test ignores such files as well; retrieved 2026-10-12), so the copy hides no code. The abort of go-arch-lint was reproduced with the pinned binary (the test above).
M1-05/28 2026-10-11 kernel-core keeps its entry kernel-core: [kernel-core], although go-arch-lint already allows it to import its own packages through commonComponents; the reason in 02 section 7.2, in the generator and in M1-05/14 is corrected. TestArchLintSelfEntry shows on the layout from 02 with the pinned go-arch-lint that kernel-core passes without the entry and chat-domain fails without its own. Minor 2 of the second red team: "only with a self entry" was not true for kernel-core. The entry stays because it changes no behaviour, keeps the uniform rule "every rule starts with its component", and dropping it would change the generated .go-arch-lint.yml, which parallel branches regenerate. Source: go-arch-lint v1.19.0 adds the commonComponents to the allowed imports of every component (allowed_project_imports.go, retrieved 2026-10-12).
M1-05/29 2026-10-11 errs.Error has MarshalJSON with a value receiver: JSON holds the exported fields under their names, with the code from PublicCode() (internal_error for a code without a kind); Cause and the wrapped error are still left out, and a nil *Error becomes null. TestJSONWritesPublicCode holds this for *Error, for Error values and for both behind an interface. Minor 3 of the second red team: json.Marshal(&Error{Code: "bogus"}) wrote "Code":"bogus", while Error() and PublicCode() return internal_error (M1-05/20). The value receiver also reaches an Error value behind an interface, which a pointer receiver does not reach; for a nil *Error, encoding/json writes null without calling the method (encoding/json Marshal, retrieved 2026-10-12). Responses are still written by the problem writer from M1-18 from PublicCode and Kind.
M1-05/30 2026-10-11 A plain scalar in the register (without quotes and without a tag) that yaml.v3 reads as a string must also be a string for a YAML 1.1 reader, as a key and as a value; otherwise tools/archgen rejects it with line, column and YAML path and advises quotes. The rule yaml11Typed follows the implicit resolvers of PyYAML 6.0.3 (booleans, numbers with _ and sexagesimal parts such as 1:20, timestamps with a time zone after white space, =, null, the merge key; the add_implicit_resolver calls in PyYAML 6.0.3 lib/yaml/resolver.py, retrieved 2026-10-12) and also rejects y, n, yes, no, on and off in any case. With quotes or !!str the value is a string for every reader; only quotes behind the non-specific tag ! (! 'no') are resolved by PyYAML like a plain scalar, and yaml.v3 keeps no trace of that tag, so the check does not see it. The number version must be written in decimal, because yaml.v3 reads 0o1 as 1 and PyYAML as a string. TestYAML11Rule and the new cases in TestRegisterRejects hold this. Minor 4 of the second red team: yaml.v3 reads the register as YAML 1.2, PyYAML (M1-06) as YAML 1.1, and no was a module name here and False there. Second instance of the class from M1-05/16 (two readers see different names), so it is fixed in one place for all resolvers instead of only for the words of the finding. A comparison against PyYAML 6.0.3 over all short number forms and all spellings of the boolean words found no scalar that archgen accepts and PyYAML reads differently; the rule is stricter than PyYAML only for y and n, which the YAML 1.1 type repository lists as booleans (bool type, retrieved 2026-10-12), and for mixed spellings, which the finding asks for.
M1-05/31 2026-10-12 Ported onto the English main: the rulings 25 to 28 of the German follow-up ticket are numbered M1-05/27 to M1-05/30, because main's M1-05/25 (port note) and M1-05/26 (clock.Clock) came first; every reference in code, tests and chapter 02 uses the new numbers. Ruling numbers are stable references, so the numbers already on main stay.

M1-07 Configuration schema and loader

No. Date Ruling Reason
M1-07/1 2026-10-11 Grammar of a duration: exactly (0\|[1-9][0-9]*)(ms\|s\|m\|h\|d), that is a number without sign, fraction and leading zero and exactly one unit, d = 24 h; an overflow of int64 nanoseconds is an error. The canonical form (output of bothy config print) uses the largest unit that divides the value without remainder (120s as 2m, zero as 0s). One spelling per unit reads without arithmetic for an operator; the chapters name retention periods in days (30d), which time.ParseDuration does not know, and its fractions and compositions (1.5h, 1h30m) would allow several texts for the same value (time.ParseDuration: units ns to h, optional fractions, retrieved 2026-10-12).
M1-07/2 2026-10-11 The zero value of config.Kind is no kind; NewSchema rejects a key without a kind. A forgotten field must not silently mean string.
M1-07/3 2026-10-11 Every value from file, environment and flag is checked, even if a higher source overrides it. A required key needs a non-empty value. A wrong value in the file must not surface only when the overriding variable goes away; an empty value does not satisfy a requirement.
M1-07/4 2026-10-11 The file is exactly one YAML document. Anchors, aliases, merge keys and explicit tags are errors, as is a scalar or null where a section stands; an empty document sets nothing. Syntax errors appear with the text of the YAML parser, except its texts about anchors, which quote the anchor name. Anchors and merge keys would let one text stand for several keys, and a tag (!!binary) would make the text mean something other than what the parser of the kind sees. The other error texts of go.yaml.in/yaml/v3 are fixed texts with a line number and quote no input (read in the v3.0.5 source of its scanner and parser on 2026-10-11, an own observation, retrieved 2026-10-12; the loader tests hold the texts this ticket relies on).
M1-07/5 2026-10-11 A BOTHY_ variable that appears twice in Sources.Env is an error; BOTHY_CONFIG_FILE set but empty means no file. Which entry wins would depend on the caller. An empty variable comes from ${X:-} in Compose and means "no file".
M1-07/6 2026-10-11 platform/config knows server.role -> --role as its only flag; any other key in Sources.Flags is a problem. 02 "Configuration loading" item 2 allows only --role and --config on the command line; this way the source of a value names the real flag name.
M1-07/7 2026-10-11 Canonical text: numbers in base ten, float in the shortest form (strconv.FormatFloat with 'g' and precision -1, which uses the smallest number of digits that reads back to the same value, strconv.FormatFloat, retrieved 2026-10-12), lists separated by , or as a JSON array if an element has a comma or whitespace at the edge or the first one starts with [, prefixes and listen addresses in the normal form of net/netip, cron fields with one space, all other kinds unchanged. bothy config print puts text with non-printable characters, whitespace at the edge or a quotation mark at the start in Go quotation marks. The output can be read in again and gives the same value; a line break in a value cannot fake a line of its own.
M1-07/8 2026-10-11 A cross check runs only if none of its keys has a problem in any source, and it sees only its keys. Its problem carries the keys joined by ,, the message <check>: <error> and per key <key> from <source>. A cross check on an invalid value would only report a follow-up error; access to an undeclared key is a programming error and ends in a panic in the test.
M1-07/9 2026-10-11 main reads os.Environ() once and passes it through run and dispatch to every command; for this, the signature of the command table from M1-05 has the parameter env. 02 "Configuration loading" item 5: only cmd/bothy reads the environment, once; tests pass their environment explicitly.
M1-07/10 2026-10-11 With type resolution, forbidigo also forbids syscall.Environ, (*exec.Cmd).Environ and http.ProxyFromEnvironment, in addition to the functions of the brief. Besides cmd/bothy/ there is a second exception: test files under tools/ (^tools/.+_test\.go$). linters.exclusions.generated is set to disable: files with the header line of generated code (// Code generated ... DO NOT EDIT.) are checked too; a ticket that introduces generated Go code exempts for its paths (*.gen.go, sqlcgen/) only the linters the code cannot satisfy, never forbidigo. syscall.Environ reads the environment like os.Environ, Cmd.Environ returns all of it when Cmd.Env is empty, and ProxyFromEnvironment routes outgoing calls through HTTP_PROXY (finding of the final review; a throwaway file proves each rule). With the default lax (and also with strict), golangci-lint 2.14 skipped a hand-written file with this header line entirely (configuration file reference, linters.exclusions.generated, retrieved 2026-10-12); with disable, forbidigo reports os.Getenv there (finding of the re-review, proven on a throwaway file). The module has no generated Go code yet, so the setting costs nothing today. The tests of tools/archgen get the pinned go-arch-lint from make test through GO_ARCH_LINT and pass the environment to the child process; tools/ is no product code and is also listed under exclude in the register. Tool code outside tests stays covered (checked with a throwaway file).
M1-07/11 2026-10-11 Empty text is no valid secret_ref, and a secret_ref key has no empty default value (rule of the brief). Keys *_ref whose chapter row names empty (such as server.tls_cert_ref and server.tls_key_ref in 16: "the server does not terminate TLS") enter the schema with the ticket that builds their user (for TLS, M1-21); that ticket defines what "no reference" looks like. The brief defines the grammar and the rule for empty default values; today no key in the schema has this case.
M1-07/12 2026-10-11 02 "Configuration loading" item 2 said that the built-in default values were generated from the configuration reference, and item 3 that bothy config print showed <redacted>. Both are corrected: the default values are in the schema (internal/wire/configkeys_*.go), equal to the chapter table, and print shows the reference. 16 needed no change. The build shows both differently. The configuration reference already said "secrets only as a reference"; 16 5.1 item 4 ("without secrets") fits both.
M1-07/13 2026-10-11 Cron without leading zeros, step n from 1 to the highest value of the field, a/n without a range is an error, weekday 0 to 6. One spelling per value, as for the duration; a/n is read differently in cron dialects.
M1-07/14 2026-10-11 Placeholders <key> apply only in default values of secret_ref keys and name a string key; inserted text is not searched again. If the named key has a problem, the reference gets no second problem. 16 5.2 needs file:<secrets.dir>/<name>; more substitution would be a template language without a user. A follow-up problem for a substitution the operator never wrote is confusing.
M1-07/15 2026-10-11 Bind checks all fields first and only then sets them; only exactly the types of the brief match (no named type such as type Role string), and unexported fields stay untouched. A half-filled Config after an error would be a silent trap; a named type could change its meaning without the binding noticing.
M1-07/16 2026-10-11 A key name may not be a section of another one (a.b next to a.b.c). In the file, b would have to be a value and a mapping at once.
M1-07/17 2026-10-11 Until M1-08, TestConfigKeysMatchChapterTables in internal/wire holds that every key of the schema stands in its chapter table with the same default. Fixed item 9 of the brief needs a test; the comparison over all keys of all chapters comes with M1-08.
M1-07/18 2026-10-11 Some grammars are narrower than the table of the brief: float without a leading + and without a trailing point (5.), ports of listen addresses and URLs without a leading zero, URL ports from 1 to 65535. One spelling per value, as for the duration (M1-07/1) and cron (M1-07/13); port 0 of a URL is no reachable target. The configuration reference names the exact grammar.
M1-07/19 2026-10-11 A file key whose segments do not have the form of key segments appears in unknown and duplicate messages only with its line and the section above it. Paths and variable names with invalid UTF-8, non-printable characters, whitespace at the edge or a quotation mark at the start appear in problems in Go quotation marks; bothy config print quotes values by the same rule. Findings of the final review: {role:secret} without a space is, in YAML, a key that contains the value; a line break in --config faked a second problem line. A secret that itself has the form of a key and stands in the place of a key is still named by the message, because the brief requires unknown keys with their names.
M1-07/20 2026-10-11 A reference env: may name neither BOTHY_CONFIG_FILE nor a variable in the form of a key variable (BOTHY_A__B). Otherwise a value would be configuration and secret at once, and bothy config print would show it as the value of a key; a reference to a mistyped key variable would exempt exactly the typo from the check that exists for it. 16 5.2 names BOTHY_SECRET_....
M1-07/21 2026-10-11 bothy config --config "" is a usage error (exit code 2). An empty path would silently fall back to BOTHY_CONFIG_FILE; a script with an empty variable would get a different file than the one it names.
M1-07/22 2026-10-11 listeners_distinct treats an unspecified address with an IPv6 zone ([::%eth0]) like ::. A literal unspecified IP address listens on all addresses (net.Listen, retrieved 2026-10-12), and [::%eth0] is such a literal with a zone; treating it like :: reports the conflict at the check instead of only when binding.
M1-07/23 2026-10-11 make lint checks the rules go:S3776 (cognitive complexity above 15) and go:S1192 (literal written three times) of the quality gate "Bothy way" with gocognit (min-complexity: 15) and goconst (min-occurrences: 3) in the module services/server. Exempt, as in the analysis, are test files (sonar.test.inclusions) and generated code (sonar.exclusions: *.gen.go, *_gen.go, *.pb.go, sqlcgen/, gen/, generated/; M1-07/28; gen/ removed by M1-17/31), plus, until M1-07/29, the files tools/archgen/generate.go and tools/archgen/validate.go from M1-05; new files in tools/archgen are checked. goconst stays at ignore-calls: true. Finding of the red team: four functions and one literal of this ticket made the gate fail, and nobody saw it before the analysis with a token. gocognit reports, like S3776, only above the threshold (gocognit, -over N shows functions with complexity above N, retrieved 2026-10-12); on the ticket state before the fix it reports exactly the four functions with the same values as the analysis. The two files of M1-05 already had findings of both rules on the M1-05 state and belong to M1-05, whose fix does not change them; the exception stays until a ticket fixes these places. Limits: goconst counts per package, the analysis per file, so the gate also reports literals spread over several files and counts literals of the two exempt files too (stricter than the analysis, never looser). goconst skips literals in call arguments, which the analysis counts (ignore-calls: "Ignore when constant is not used as function argument", default true, and min-occurrences: "Minimum occurrences of constant string count to trigger issue", golangci-lint linter settings, goconst, retrieved 2026-10-12); with ignore-calls: false it reported, on this state, key names and other literals spread over several files of a package that the analysis does not report per file. A literal that appears three times as a call argument in one file is therefore found only by the analysis.
M1-07/24 2026-10-11 The chapters of the keys are constants chapter<NN> in internal/wire/configchapters.go, one per chapter; config.Key.Chapter names only these constants. A chapter with several keys would otherwise write its file name several times (go:S1192). The name from the chapter number is the same for every ticket: if two parallel tickets add the same constant, the lines are equal, instead of two names for one chapter or a duplicate declaration in two files.
M1-07/25 2026-10-11 A file entry that the loader rejects as a whole (section or value with a tag or an anchor, duplicate or dotted key, section without a mapping, key that is not a plain scalar) counts for cross checks (M1-07/8) and placeholders (M1-07/14) as a problem of every key at or below it, a merge key for its section; an entry whose name does not have the form of a key rejects nothing. A file that is not read (missing, unreadable, no valid YAML, more than one document, root with an anchor, with a tag or without a mapping) counts as a problem of every key. A duplicate variable counts as a problem of its key. Finding of the red team: after a rejected section server, listeners_distinct ran on the fallback value and reported a follow-up error about a value the operator wanted to replace.
M1-07/26 2026-10-11 Error texts of the flag package in bothy config stand entirely in Go quotation marks by the rule of M1-07/19 if they contain invalid UTF-8, non-printable characters, whitespace at the edge or a quotation mark at the start. Finding of the red team, second place of the same error class as --config: flag puts the flag name unquoted into the message, a name with a line break faked a problem line, and an ESC byte reached the terminal. An ordinary flag name stays readable unchanged.
M1-07/27 2026-10-11 forbidigo also checks example functions in tests (exclude-godoc-examples: false); the only exceptions stay those of M1-07/10. Finding of the red team: the default of forbidigo silently exempted func Example..., a third exception named nowhere. Examples are documentation that readers copy; the setting changes nothing in the shipped code ("0 issues.").
M1-07/28 2026-10-11 The exemption for generated code in services/server/.golangci.yml covers every pattern of sonar.exclusions that means generated Go code: *.gen.go, *_gen.go, *.pb.go and the directories sqlcgen/, gen/ and generated/ (gen/ removed by M1-17/31). tools/ci/tests/test_server_lint_config.py classifies every entry of sonar.exclusions (generated Go code, or no Go code that golangci-lint reads) and checks that gocognit and goconst skip generated Go code, check product code with a similar path, and skip no path that looks like generated code but is analysed; a new or removed entry in sonar.exclusions needs a decision there, and a rule that reaches too far after a removal makes the test fail. Minor 2 of the second red team: generated Go code in a directory gen/ or generated/ made make lint fail at goconst, although the analysis leaves it out. Under build/ the lint gate stays stricter than the analysis, because no generated code is meant to lie there. The lint gate may be stricter than the analysis, never looser (M1-07/23). The parallel state of M1-17 (ruling M1-17/15) removes **/gen/** from sonar.exclusions; without the second direction the rule would still silently skip gen/ after both merges.
M1-07/29 2026-10-12 The exemption of tools/archgen/generate.go and tools/archgen/validate.go from gocognit and goconst (M1-07/23) is removed. The SonarQube Cloud fixes of PR #6 brought both files under both rules per file; goconst counts per package, so the component names wire, cmd, api and testsupport, which both files write, are now constants in generate.go. M1-07/23 kept the exemption until a ticket fixes these places; PR #6 did, and the exemption would otherwise hide new findings in the two files.

M1-22 Egress client and SSRF lint

No. Date Ruling Reason
M1-22/1 2026-10-11 The client writes the counter bothy.egress.blocked through the interface egress.Metrics (Blocked(ctx, Reason)) in a fourth field Deps.Metrics; without Metrics it counts nothing. The OpenTelemetry implementation comes from platform/otel (M1-10), the wiring from the ticket of the first consumer. TestBlockedAttemptCounted checks with a recording fake instead of a manual reader of the SDK. The brief requires only the standard library for egress, and the server module has no OpenTelemetry dependency yet; a manual reader would need the SDK in go.mod.
M1-22/2 2026-10-11 A rejected attempt logs bothy.egress.purpose (new in signals.md 5.3 and in the allow list of infra/otel/collector.yaml), bothy.reason and server.address with the message egress request blocked. signals.md 7 allows only attributes from 5.3 in logs; there was no key for the purpose from SEC-NET-5, and for the host there is server.address from the semantic conventions. The allow list must stay equal to 5.3, so the collector file changes with it.
M1-22/3 2026-10-11 TestSECT09_RedirectToPrivateRefused checks two targets: https://internal.example.com:<port>/ (allowed only by the policy, resolves to 127.0.0.1) ends with address_blocked, http://internal.example.com:<port>/ with redirect_not_allowed; the target counts 0 requests in all cases. The brief describes the redirect to http:// with address_blocked; by design point 2, http without an entry with allow_private already fails before the connection. This way both paths are covered.
M1-22/4 2026-10-11 The entries of egress.allowed_hosts are checked by the cross check egress_allowed_hosts in internal/wire with egress.ParseRule, not by a new kind in platform/config. Duplicate means the same host and the same port after normalization, regardless of allow_private; host and host:443 are different entries, and allow_private applies to a request if one of the matching entries carries it. Messages name positions (element 2, elements 1 and 3), never values. The tests of the three keys and their environment names live in internal/wire. platform/config has no project dependencies (M1-07) and knows no keys; the brief names ParseRule as the only parser.
M1-22/5 2026-10-11 Grammar of ParseRule beyond the brief: labels from a-z, 0-9, - and _, the last label is no number (1.2.3, 0x7f000001), port without a leading zero, IPv6 in brackets also without a port, no zone, an IPv4-mapped IPv6 address becomes the IPv4 address. Hosts of request URLs go through the same grammar (uppercase becomes lowercase, a trailing dot is dropped); other forms (zone, characters outside ASCII) end with url_not_allowed. _ occurs in service names of Compose; resolvers following inet_aton read numeric names as an IPv4 address (the forms a, a.b, a.b.c and a.b.c.d, each part "in decimal, octal (with a leading 0), or hexadecimal", inet_aton(3), retrieved 2026-10-12); one spelling per value, as for the kinds from M1-07.
M1-22/6 2026-10-11 egress.max_response_bytes also limits the header lines of a response (Transport.MaxResponseHeaderBytes); a value of the consumer (Options.MaxResponseBytes) changes only the limit of the body. The body counts the bytes the caller reads, so decoded bytes for gzip negotiated by the transport (the transport decompresses such a response transparently, Transport.DisableCompression, retrieved 2026-10-12). Otherwise the default of net/http (10 MiB, transport.go, retrieved 2026-10-12) would apply to header lines without a named value; the default of 1 MiB equals the server's limit for request header lines.
M1-22/7 2026-10-11 The decision of the check before the connection (a matching entry with allow_private) travels in the context of the request to the dialer. The dialer itself limits resolution and connection setup with the timeout, and the addresses of a name share the remaining time equally. The transport also sets the timeout as the deadline of every redirect step; http.Client.Timeout of Do and HTTPClient limits the whole chain. HTTP/2 stays off. net/http detaches the dial from the cancellation of the request (Go 1.27, context.WithoutCancel, transport.go, retrieved 2026-10-12); without a deadline of its own, a hanging resolution would continue (TestTimeoutBoundsResolver). Without the split, an address that never answers would use up the whole time (TestDialSplitsTimeAcrossAddresses, finding of the final review). The deadline in the transport also applies when a library puts the transport into its own client without a timeout, but then per step (TestTransportDeadlineWithoutClientTimeout, TestTimeoutCoversAllHops). The connection pool of net/http distinguishes scheme, host and port, from which the decision follows.
M1-22/8 2026-10-11 Client.Do returns errors without path, query and user part (instead of *url.Error, an error with method, scheme and host that keeps Unwrap and Timeout) and, for a rejected redirect, only the error. SEC-LOG-3: callers log errors; *url.Error carries the whole URL.
M1-22/9 2026-10-11 Beyond the list of the brief, bothy-go-egress-only also reports references without a call (get := http.Get, net.Dial as a function value), new(...) of http.Client, http.Transport, net.Dialer and tls.Dialer, tls.Dialer{...}, net.DialTCP, net.DialUDP and net.DialIP (the last two since a review comment on pull request 15), httputil.ReverseProxy (also as a zero value, field or collection since a review comment on pull request 18, because its Transport "If nil, http.DefaultTransport is used": net/http/httputil reverseproxy.go of Go 1.27.2, retrieved 2026-10-12) including NewSingleHostReverseProxy and x.Transport = nil, plus values of these four types (and of httputil.ReverseProxy) and their slices, arrays and maps (also with an array as the key) in var, literals, type definitions, aliases, named struct fields (also directly in an anonymous struct), anonymous structs and as an embedded field in gofmt form; embedded pointers stay allowed. Not covered are dot imports, renamed imports in the value forms (the explicit patterns of the list follow them), parameters and return values, deeper nesting and clients of third-party libraries that use http.DefaultClient internally; test code (*_test.go, test/, tests/, testsuite/, services/*/perf/) is exempt. A zero-value client, a zero-value dialer and a reverse proxy bypass the check just like the cases of the brief (findings of the final review and the re-review, each with a ruleid or ok case in the fixture). A regular expression over the written type reads the value forms; Semgrep does not distinguish an embedded field from an embedded pointer, so a line rule checks the embedded value. A rule over the syntax does not see clients of third-party libraries; they get Client.HTTPClient(), and their ticket checks that.
M1-22/10 2026-10-11 W9: 02 "6. Code layout" already listed egress/; the module register now has platform.egress without a vendor group, and .go-arch-lint.yml is generated again. PLAN, "Known contradictions that M1 resolves", W9.
M1-22/11 2026-10-11 NewLoopback has the signature of the brief without an error and aborts with a panic for a timeout <= 0. A client without a timeout is a programming error; the caller passes a checked configuration value.
M1-22/12 2026-10-11 The golden test of bothy config print in cmd/bothy lists the three new keys; the configuration reference (section 2, row "Egress target") names the built grammar and the JSON form for an environment list that starts with an IPv6 literal in brackets. The test lists all keys of the schema. An environment value that starts with [ is a JSON array by the list grammar from M1-07 (TestEgressIPv6FirstNeedsJSONArray).
M1-22/13 2026-10-11 New file .semgrepignore with the built-in ignore list of Semgrep 1.180.0 (default .semgrepignore, retrieved 2026-10-12) without the test paths (test/, tests/, testsuite/, *_test.go). The rules meant only for product code (bothy-go-no-model-credentials, bothy-go-sql-string-building, bothy-go-route-registration and bothy-go-egress-only) exclude test paths in their own paths; their effect stays as before. Without the file, semgrep scan skipped every test file, so bothy-go-tls-verify checked no test, contrary to the brief, which says "No path exclusion, also not for tests" (finding of the final review; proven with a throwaway copy: a test file with InsecureSkipVerify: true had no finding, with the file it is reported).
M1-22/14 2026-10-11 A response 101 Switching Protocols is an error for Do, HTTPClient and NewLoopback; a redirect (301, 302, 303, 307, 308) whose Location cannot be read ends with an error with a fixed text in both redirect modes. After 101, net/http hands over the raw connection as the body, and no deadline limits reading any more; an allowed target could hold goroutine and connection for any length of time (finding of the final review, TestProtocolSwitchRefused). net/http also turns an unreadable Location into an error with http.ErrUseLastResponse and quotes the header including path and query (TestRedirectWithInvalidLocation); with RedirectNever, the caller therefore gets an error instead of the 3xx response in this case.
M1-22/15 2026-10-12 fd00:ec2::254 (IPv6 metadata endpoint of one cloud provider, inside fc00::/7) and host .200 of 100.100.100.0/24 (metadata endpoint of another, inside 100.64.0.0/10; code and tests spell it as bytes, because the generic deny-list rule refuses a host address of that range as text) are always blocked, also with allow_private; 19 SEC-NET-3 names them next to the link-local range. Sources: the IPv6 address of the instance metadata service is [fd00:ec2::254] (Amazon EC2, Access instance metadata, "IPv6 support", retrieved 2026-10-12); the metadata server of the other provider answers at host .200 of 100.100.100.0/24 (Alibaba Cloud ECS, View instance metadata, retrieved 2026-10-12). R-SE-10 says that an operator entry opens an internal target, not the metadata of the clouds; before, both addresses lay only in ranges that allow_private opens, so a rule whose name resolves there reached the metadata service. Review comment on pull request 11; TestSECT09_BlockedRanges holds both addresses, their neighbours and the mapped form.

M1-74 CodeQL for Go

No. Date Ruling Reason
M1-74/1 2026-10-11 Beyond the rules of the brief, tools/ci/tests/test_codeql_workflow.py checks further acceptance criteria of the workflow: setup-go stands before init and sets cache: false (also written as 'false', because setup-go reads the value as a boolean input: core.getBooleanInput('cache') in setup-go src/main.ts, retrieved 2026-10-12), the build step sets CGO_ENABLED: "0", init passes languages: ${{ matrix.language }} and queries: security-extended, and analyze follows init and uploads under /language:${{ matrix.language }}. The job and every step set only options from closed lists (JOB_OPTIONS, STEP_OPTIONS); every further step, also a second setup-go step, and a matrix axis next to include are findings. Conditions are enforced, not assumed: otherwise only a review would hold what the brief justifies (no unverified cache, the shipped build configuration). Review and red team found, one after the other, options with which a matrix entry ends green without an analysis (a condition, continue-on-error, upload: never, a CodeQL configuration with path filters); instead of forbidding each one, the test knows every allowed option, and a new one is a deliberate change of the test. Next to an axis, GitHub merges the include entries with the combinations of the axis: an axis value without an entry runs without build-mode, and with a foreign axis a later entry overwrites values of an earlier one (an entry is added to every combination it does not overwrite, otherwise "it is added as an additional matrix combination", and a later entry "overwrites the color: green that was added by the previous include entry": Running variations of jobs in a workflow, "Expanding or adding matrix configurations", retrieved 2026-10-12). Top-level keys, further jobs and the trigger pull_request are closed in the same way since M1-74/6. Not covered are the values of allowed keys without a rule of their own (such as concurrency, the value of timeout-minutes, the single permissions at the top and in the job, the triggers next to pull_request, keys under strategy next to matrix; check_runners checks its own rules for these, which its docstring lists, such as the runner switch, trigger rules, that top-level permissions are set and not write-all, that a timeout is set, and the pins), a job condition of the form (<guard>) && ..., which check_runners.has_public_guard accepts on purpose (the brief prescribes has_public_guard), and settings outside the file (code scanning of the repository on GitHub). The trial run in docs/quality/public-switch.md shows the settings outside the file.
M1-74/2 2026-10-11 test_public_switch_runbook_names_every_codeql_job requires that step 18 "Trial run" in docs/quality/public-switch.md names the job analyze (<language>) of every matrix entry. The runbook names the jobs that must be green after the switch to public; without a test, it would become incomplete unnoticed with the next matrix entry (javascript-typescript).
M1-74/3 2026-10-11 The path patterns of LANGUAGE_SOURCES are evaluated with fnmatch.fnmatchcase: * also matches across /, and case matters on every operating system. Test and Makefile find the same manifests (M1-74/5): the git pathspecs of the Makefile discovery also let * match across / and are case-sensitive ("in particular, * and ? can match directory separators", gitglossary, pathspec, retrieved 2026-10-12; case-insensitive matching needs the magic word icase); fnmatch.fnmatch would not be on Windows ("Both parameters are case-normalized using os.path.normcase()", while fnmatchcase "is case-sensitive and does not apply os.path.normcase()"; "the filename separator ('/' on Unix) is not special to this module", fnmatch, retrieved 2026-10-12).
M1-74/4 2026-10-11 The build step sets CGO_ENABLED as the YAML string "0"; an unquoted 0 and every other spelling of the number 0 (0b0, 0_0, 00) are findings. check_runners.load_yaml resolves numbers by YAML 1.1 and reads all these spellings as the number 0. GitHub reads by YAML 1.2 [assumed, not checked offline] and passes 0b0 and 0_0 on as text (the integer forms of the core schema are decimal, 0o and 0x only: YAML 1.2.2, 10.3.2 Tag Resolution, retrieved 2026-10-12), and Go takes only exactly 0 as off (CGO_ENABLED=0b0 go env CGO_ENABLED gives 1); the build on the runner would run with cgo. After load_yaml, an unquoted 0 (also off for GitHub) can no longer be told from 0b0; only the string is unambiguous, and the brief writes "0". The string is compared as it stands: Go also accepts " 0" as on. load_yaml already reads booleans by YAML 1.2 (no stays text), so the rule for cache: false stays unchanged.
M1-74/5 2026-10-11 The component patterns of LANGUAGE_SOURCES (COMPONENT_MANIFESTS) are the git pathspecs of the component discovery of the root Makefile, verbatim: services/*go.mod, sdk/python/pyproject.toml, plugins/*pyproject.toml, sdk/typescript/package.json, plugins/*package.json. test_component_manifests_are_the_makefile_discovery keeps them equal to GO_MODS, PY_PROJECTS and TS_PROJECTS and requires an assignment for every further discovery variable of the Makefile (DART_PKGS: none, CodeQL does not support Dart: the language table of Supported languages and frameworks, retrieved 2026-10-12, lists no Dart). A discovery is an assignment in a line without a leading tab (any operator, also after export or override, also across line continuations) whose value calls $(dir ...) or ${dir ...} and git ls-files; its pathspecs are the arguments of every git ls-files in it without options and redirections, and += appends. Not recognized are a component list without $(dir ...) or without git ls-files and one that is built through define, $(eval ...) or $(call ...). The modules for the Go rules come from every pattern with the language go. The narrower patterns of the table in the brief (services/*/go.mod, plugins/*/pyproject.toml, plugins/*/package.json) no longer apply. That * in these pathspecs crosses / is the pathspec rule cited in M1-74/3. The table of the brief names mirroring the Makefile discovery as its purpose, and each of its rows still maps the same way. With the narrower patterns, a tracked services/go.mod would be a Go module for the Makefile, on which all server gates run, but invisible to CodeQL; that is the silent gap the goal of the ticket rules out. Narrowing the statement to the planned layout would have left the gap open.
M1-74/6 2026-10-11 codeql.yml sets at the top level only name, on, permissions, concurrency and jobs (WORKFLOW_OPTIONS); jobs contains only analyze; on contains pull_request, whose only allowed filter is branches; it then names main (as a list or a single value) and no pattern with ! (PULL_REQUEST_OPTIONS). The same error class as in M1-74/1, found for the third time: a top-level env (GOFLAGS, GOTOOLCHAIN, CODEQL_ACTION_DEPENDENCY_CACHING, CODEQL_EXTRACTOR_GO_OPTION_EXTRACT_TESTS) acts on every step and a top-level defaults (run.shell) on the build step, without changing a step; a further job would lie outside every rule and could upload under the categories of analyze; without pull_request or with a filter such as paths or types, pull requests would stay unanalysed without a red check, as with a later pattern such as !main, which GitHub applies after main ("A matching negative pattern (prefixed with !) after a positive match will exclude the Git ref", workflow syntax, on.pull_request.branches, retrieved 2026-10-12). The closed lists thus reach up to the top level of the file.

M1-06 Module register check

No. Date Ruling Reason
M1-06/1 2026-10-11 register-check compares and generates nothing. From the tables of 02 it reads only the machine columns (Owns tables, May use, Package, Actions, the three columns of 4.7), from 03 the first column and the column Tables of "Modules" and the line Module: per table. The tables of 02 mix machine columns with prose columns that people maintain ("Responsibility", "Reason per edge"); a generator would have to write the prose too or move it into a second source.
M1-06/2 2026-10-11 02 "Event types and WebSocket ops per module" now also lists audit, billing, apps and search with empty cells. Every module table in 02 and 03 has exactly one row per module of the register. Finding of the check on the real tree (C9: "module apps missing" and three more). With one row per module in every table, a new module shows up everywhere it is missing; reading a missing row as the empty set would have hidden exactly that.
M1-06/3 2026-10-11 The READMEs of instance and billing write the dependency line like all others: May use the ports of these modules: none. Finding of the check on the real tree (C12: the line was missing; it said that the module must not use the ports of any other module). One form instead of two; the form of the line has been fixed since M1-05/10.
M1-06/4 2026-10-11 Besides the dependency line and the tables, C12 also checks the actions and the three lines on events and ops in every module README, although the brief names only the first two. It reads the whole paragraph of the dependency line (list lines without a blank line before them belong to it, as in Python-Markdown), the whole sections "Tables" and "Actions (05-actions.md)" and each of the three list items with continuation lines, nested items and indented paragraphs; any other line in the section "Events and WebSocket ops" is a finding. A README line or section without names says none; empty, it is a finding. A README under services/server/internal/<dir>/ is a finding if <dir> is no register module and not kernel, platform or wire. The goal of the ticket is that the READMEs equal the register; PLAN section 3, item 7 (documentation duty) requires tables and actions in the README, and M1-05/10 took exactly these parts from 02. On the real tree they already matched. The site shows a name on a continuation line, in a second paragraph, in a nested or further list item or in the README of a removed module like any other; the final review and the red team found that it otherwise dropped out of the comparison unnoticed.
M1-06/5 2026-10-11 Fail closed when reading: a cell, line or section without names must be empty or start with the word nothing or none, and one that starts like this names no name ("nothing (formerly instance)" is a finding). Names are code spans as in Python-Markdown (backtick runs of equal length, also <code>); a known name (module, register entry, action, event type or op of the catalogue) outside a code span and raw HTML are findings. The check does not read what the site hides: code blocks and HTML comments (a <!-- in a code span is text, a line that starts with three backticks and more backticks is no code block). The check recognizes a table as Python-Markdown does: a paragraph whose second line has as many cells and consists only of \|, :, - and spaces; every further line of the paragraph is a row of the table, and a pipe character in a code span splits no cell. It reads ATX headings as Python-Markdown does (# in the first column, also without a space after it); it does not read Setext headings. A level-4 heading under "Tables" in 03 is exactly one table name in backticks; a level-3 heading that starts with a code span and every heading of level 5 or 6 are findings. A title appears exactly once in its file. If a file, a section, a table or a column is missing, or if the register or the catalogue is invalid, the check ends with exit code 2 and one line. Prose such as "all" must not silently become the empty set, and a name must not silently drop out of the comparison or get into it because the check reads it differently from the site. The final review, the re-check and the red team found form after form for this; M1-06/11 holds the class, and these rules give the fast feedback in the gate.
M1-06/6 2026-10-11 The check reads the diagram through a closed line grammar: every line is front matter, blank, a %% comment or exactly one statement, namely a keyword line (flowchart, subgraph, end, classDef, class, style, linkStyle, click, direction), an edge a --> b, a layout line a ~~~ b or a node declaration (id, id[...], id(...), id{...}, id>...], id@{...}, each with an optional :::class); a ; or & outside labels and any other line are findings. Nodes are the declared ones and every id that an edge, a layout line or style, class and click name, without subgraph ids. Besides the modules, only kernel and platform are allowed; every edge connects two modules. A test renders the real diagram with the pinned mermaid-cli of make diagrams and requires that nodes, edges and layout links in the SVG equal the static reading. A closed grammar gives fast feedback in the gate without a browser; the same error class (Mermaid reads a line differently from the check: node forms, style and ~~~ create nodes, ; separates statements) came back in the final review and both re-checks, so, as for Markdown, a test against the real artifact holds it (M1-06/11), with the renderer that make diagrams already uses. kernel and platform stand for the register sections of the same name; the diagram does not draw edges to them (docs/architecture/README.md "Module edges", comment in the diagram).
M1-06/7 2026-10-11 register-check stands in gates directly after arch, not after api-lint. The test does not pin the position, only that the target is in gates and .PHONY, and it calls make register-check. M1-03 and M1-04 put api-checks and ops-check directly after api-lint, M1-02 catalog-check directly before it; in content, the comparison belongs to the module boundaries. Like catalog-check, it always runs, without SKIP.
M1-06/8 2026-10-11 The brief counts three modules without tables; 02 "Rules" item 7 and 03 "Modules" name two since the addendum of 2026-10-10 (gateway, search). The check reads the mark yes or none from 03 and has no fixed number. No action of the catalogue was missing in 02; the assignment rule of the brief for such actions did not apply. As in M1-05/1: the number in the brief dates from before the addendum; no gap in the core contract (P-11).
M1-06/9 2026-10-11 register_check.py loads the catalogue through load_catalog from tools/catalog and for this puts tools/catalog at the front of sys.path, as tools/ci/go_prereqs.py does for tools/bin; tools/catalog stays unchanged. The brief forbids parsing actions.yaml a second time. The Makefile calls the file as a script, without the pythonpath of pytest; a test calls it exactly like this.
M1-06/10 2026-10-11 Outside the file list of the brief, the overview of the gates in docs/spec/23-quality.md (section "Gates", group Code) and services/server/README.md ("Rules that the gate enforces") also name the new gate. Both lists named every gate so far; without the entry they would be incomplete after this ticket (fixed requirement 4 of the brief: a change without matching documentation is not done). Finding of the final review.
M1-06/11 2026-10-11 A test against the real artifacts, not another reading rule, holds the error class "the check reads differently from the site": tools/doccheck/tests/test_register_check.py renders 02, 03 and every module README with the Markdown configuration from mkdocs.yml and requires the same reading for every place the check reads: sections and their uniqueness, header row and read columns of every table, headings and owners in 03, the paragraph of the dependency line, sections and list items of the READMEs, each with names, a leading nothing or none and words outside code. The same applies to the diagram with mermaid-cli (M1-06/6). Demonstration tests show that the comparisons catch a second Setext heading, a table in a quote, a hidden span and an edge chain. For the READMEs, Python-Markdown stands in for the renderer of GitHub. The same error class came back in the final review, in both re-checks and in the red team (comments, code spans, fences, separator rows, lists, hidden HTML, Mermaid lines). By the project rule for recurring error classes, it is held in one place with a test against the real artifact (model M1-02/13); the tool itself stays with the standard library and PyYAML (tools/README.md), and only the tests render Markdown and Mermaid. Not covered: differences that only GitHub renders differently from Python-Markdown; docs/quality/gates.md states this.
M1-06/12 2026-10-11 A relative MMDC_PUPPETEER_CONFIG counts from the repository root: render_diagrams.mmdc_command resolves it against REPO_ROOT, and an absolute path stays as it is. CI sets the variable in every step that reaches make diagrams or make test, so also for make sonar, per step as before for make gates and not at the level of the workflow. A test derives these steps from all workflows and the prerequisites of the Makefile (make -p) and requires the value from ci.yml there; ci.yml may set it in several places, but only with one value, and YAML reads quotation marks as syntax. The diagram test from tools/ takes a configuration that is already set, otherwise the one of CI. Red team M1-06 (first round), H1, M1 and M2, one cause: the path counted from the working directory of the caller, and only the step make gates set it. make sonar runs make test first, whose diagram tests would have started Chromium with a sandbox (very likely red on the Ubuntu 24.04 CI runners, because Ubuntu from 23.10 on keeps the Chromium of Puppeteer from using user namespaces through AppArmor, Puppeteer troubleshooting, "Issues with AppArmor on Ubuntu", retrieved 2026-10-12; not reproducible here without Linux); the test overwrote an own Chromium configuration and then failed without a downloaded browser. Resolving in one place makes the working directory irrelevant; the value per step keeps what CI sets visible at the step, and the test catches a new step without the variable.
M1-06/13 2026-10-11 register-check reads the register as tools/catalog reads the catalogue (M1-02/16): a value that Python cannot build (invalid date, number above the digit limit) and nesting that is too deep end with one line and exit code 2, a recursive alias is a line line <n>: recursive alias, and the check reads aliases once per node. The walk is a copy of the private _node_findings from tools/catalog. Red team M1-06 (first round), M3: reviewed: 2026-02-30 ended with a traceback and exit code 1, a recursive alias with RecursionError. M1-02 fixed the same error class in the catalogue; tools/catalog does not offer the walk publicly (tools/catalog/README.md "Interface for later tickets"), and this assignment does not change tools/catalog.
M1-06/14 2026-10-12 Ported onto the English main: the check reads the translated chapters and READMEs (section and column titles, the line Module: in 03, the marks yes and none, the empty words nothing and none, the README line May use the ports of these modules:), and its test fixtures are English. Two prose cells of 02 4.7 and the matching README lines of stats and terminal say "shared WebSocket hub" and "none on the shared WebSocket; separate connections" instead of naming the gateway and channels, because the English words gateway and channels are known names (a module and a table) and the check rejects a known name outside a code span. All repository content is English (D-W-94); the German prose used inflected forms that matched no name, the English prose did, and the rule of M1-06/5 is kept rather than weakened.

Leak gate for pull request text

No. Date Ruling Reason
LG/1 2026-10-12 The report of the independent review stays local in .review/<sha>.md; the pull request (section "Review" of the template) names the reviewed commit, the verdict and the fixes in fresh words. Pull request title and body are written fresh, without raw reviewer, bot or agent output, local paths, user or machine names or temporary folder names. Replaces the "Review report" part of D-W-50. The body becomes the squash commit message on main (push discipline, repository setting), so raw review output pasted into a body ends up in the public history of main, and a commit on main is not rewritten without the owner.
LG/2 2026-10-12 denylist_scan.py checks PR_TITLE (all rules) and PR_BODY and PR_BODY_FILE (like a commit message: identity trailers only against the private list) when set; both deny-list jobs of ci.yml pass title and body from the event payload through the environment. A Signed-off-by line in the body passes only with a full identity Name <email> from allow-authors.txt (a bare allowed e-mail is not enough there); Co-authored-by and the text get the rules as in a commit. A PR_BODY_FILE that cannot be read is a configuration error (exit 2). The squash commit does not exist when CI runs, so there is no author to compare the sign-off with; the allowed identity is the only one that may appear. Environment variables keep untrusted text out of the script (zizmor, template injection). Fail closed as for a missing list.
LG/3 2026-10-12 ci.yml runs on pull_request types opened, synchronize, reopened and edited; an edit runs the whole workflow again, not only the deny-list jobs. The ci-ok job has no needs: it starts with the run and tools/ci/ci_ok.py waits for the other jobs through the API (actions: read, also granted by the gates call in release.yml) and applies the same rules as before. Residuals (event delivery delay, manual dispatch or re-run of an old run, text typed into the merge dialog) are listed in push-discipline.md; denylist-main.yml stays the check after the merge. ci-ok is the only required check and is evaluated per head commit; a run that skipped gates on an edit would have to pass ci-ok without them. Red team (reproduced from run data): with needs, the check run of ci-ok appears only when gates ends, so for that whole time the older green ci-ok was the latest result and an edited body could be merged. The extra full run costs nothing: "GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners" (GitHub Actions billing, retrieved 2026-10-12).
LG/4 2026-10-12 CodeRabbit writes its high-level summary into the walkthrough comment (high_level_summary_in_walkthrough: true), not into the body. With edited every bot edit of the body would restart CI, and the bot summary would become part of the commit message on main. https://docs.coderabbit.ai/reference/configuration, retrieved 2026-10-12 [verified].
LG/5 2026-10-12 An identity trailer counts only in the exact form Signed-off-by: Name <local@domain> or Co-authored-by: Name <local@domain> with a plain address (letters, digits and ._%+- before the @, dot-separated labels after it). The private list checks the whole line; the other rules check the line without the local part of the address, so name and domain are still checked and only the contributor's own address is exempt. A trailer line with any other text is ordinary message text with all rules. Commits and tag messages are read with NUL as the record separator, so separator bytes inside a message stay message text. Applies to commit messages and pull request bodies alike. Red team, two rounds (reproduced): first any line starting with Signed-off-by: skipped the generic rules, then any text inside <...> did (home path, private IP, tailnet name, ID), and an allowed bare e-mail let another name pass in a body. The class is fixed at one place: only the local part of a plain address is ever exempt. A message with the old separator bytes could hide text from every rule.

M1-08 Configuration reference from the schema

Ruling L4 holds in the gate from here on: the source of a server key is the schema in the code, section 4 of the configuration reference is generated from the schema and the chapter tables, and make config-doc-check keeps both equal. The rows W1-1 to W1-10 follow the table "Configuration names (W1)" in PLAN; the evidence for each is make config-doc-check without a finding, that is D1 (no name twice) and D7 (no retired name outside the allowlist), plus the test test_real_repository_has_no_duplicates_and_no_retired_names, which holds both counters at zero. By P-08 every chapter leads its own term; where PLAN and a chapter disagreed, PLAN is corrected in this change (introduction of PLAN).

No. Date Ruling Reason
M1-08/W1-1 2026-10-11 Public listener: only server.listen_addr with the default from 16; http.listen_addr is retired (tools/configdoc/retired.yaml). P-08: process keys belong to 16. No chapter names the old name; only the W1 table in PLAN describes it.
M1-08/W1-2 2026-10-11 Internal listener: only server.internal_listen_addr (16); http.ops_listen_addr is retired. As W1-1; PLAN names the old name in the W1 table and in W2.
M1-08/W1-3 2026-10-11 Trusted proxies: only server.trusted_proxies with the default from 16; 06, 08 and 19 refer to it. api.trusted_proxy_cidrs and net.trusted_proxies are retired. PLAN said "with the default from 19" and is corrected. 19 has no default of its own and refers to 16 under "Values of other chapters"; P-08 puts process keys (server.*) into 16.
M1-08/W1-4 2026-10-11 Largest JSON body: only http.max_json_body_bytes (02); api.max_json_body_bytes and http.max_body_bytes are retired. Resolved in M0; 06 and 19 name only this name and refer to 02. The brief of M1-08 names an old name in backticks and is therefore in the allowlist of D7 (M1-08/4).
M1-08/W1-5 2026-10-11 HTTP timeouts and headers (http.read_header_timeout, http.idle_timeout, http.max_header_bytes): one row each in 02, 19 refers to them. One default per name; the rule "the stricter default applies" (P-08) has nothing left to decide.
M1-08/W1-6 2026-10-11 Database: prefix database.*; the connection in 16, the timeouts in 02. database.statement_timeout and database.lock_timeout apply in the request path and in jobs, migrations have migrate.lock_timeout (16). The six names db.* from 02 are retired. PLAN said "jobs and migrations have their own names" and is corrected. 02 "Rules for jobs" item 6: SQL in jobs runs in the same unit of work as in the request path.
M1-08/W1-7 2026-10-11 Shutdown: shutdown.* from 02; server.shutdown_grace is retired. The step budgets fit into shutdown.total_timeout (02 15.3); Compose sets a longer stop_grace_period in the anchor x-api (services api, api-tailscale) and for worker, and D8 holds this for every service that runs the server (M1-08/5). 02 "Shutdown": the orchestrator must wait longer than the server. Compose needed no change.
M1-08/W1-8 2026-10-11 Validity of the enrollment code: only auth.enrollment_code_ttl in 08. P-08: security and identity values belong in the domain chapter; 06 no longer has a row.
M1-08/W1-9 2026-10-11 Duplicate rows of the same value stand only in the domain chapter: auth.local_password_enabled (08), file.upload_part_bytes and file.upload_session_ttl (12), command.autocomplete_timeout (09), server.role (16). P-08; D1 finds no duplicate.
M1-08/W1-10 2026-10-11 Images: file.image.* in 12; upload.image_max_pixels and upload.strip_image_metadata are retired, 19 refers to it. The acceptance test of M3-06 in PLAN still named upload.image_max_pixels and now names file.image.max_pixels. The chapter applies (PLAN, introduction); D7 exempts PLAN because the W1 table must name the old names, so this place had to be corrected by hand.
M1-08/1 2026-10-11 auth.dpop_nonce_ttl stands only in the table of 08 (K-ID-8); 04 names it only in prose and under "Values of other chapters". No change to the core contract; the ticket is not BLOCKED. Brief M1-08 and P-11.
M1-08/2 2026-10-11 The generator moved from tools/docs/ to tools/configdoc/configdoc.py; its rules for chapter tables stay (shorthand / .burst, lists, column Place, default place per chapter, chapter 23 exempt, links to ../spec/). A duplicate no longer aborts but is finding D1; a heading, header or row that breaks the rules is finding table (M1-08/9); an input file that is missing, unreadable or malformed (schema, planned.yaml, retired.yaml, Compose, chapters, reference) and missing, repeated or swapped markers end with exit code 2. Without --check the tool always writes section 4 and then reports the findings of D1 to D9 with exit code 1. One generator instead of two (brief). If writing were refused on a finding, the next ticket with a new key could not proceed: after go generate D6 reports the now stale rule, and a retired name in section 4 could only be removed by hand (findings of the closing review). What the reference shows is still checked by make config-doc-check.
M1-08/3 2026-10-11 A rule in planned.yaml is stale (D6) when it is the deciding rule for no planned server key: the exact name before the prefix, the longer prefix before the shorter one. The milestones are written per namespace as a prefix with the most frequent milestone, with exceptions as more exact rules; every rule names the tickets that back it in its comment. A rule that only matches schema keys or is fully covered by more exact rules no longer tells anything. One reader per chapter group collected the milestone of every key from PLAN and the M1 briefs, and a second independent checker recomputed every assignment (no difference).
M1-08/4 2026-10-11 D7 counts a retired name in code and its BOTHY_ form anywhere, each only as a whole name (db.system.name, http.listen_addr_v2 and BOTHY_DB__LOCK_TIMEOUT_X are other names). Code is every line in a fenced code block (backticks or tildes, at any indentation) and every code span after CommonMark with GitHub tables (code_parts): a run of N backticks that is not escaped, up to the next run of exactly N, across line breaks of a paragraph, list item or quote, never across a blank line, a heading, a table row or table cell, or a new list item. These block rules apply to Markdown files; in other text files, where # starts a comment, only blank lines and fences end a paragraph. Indented code blocks and nested YAML do not count. Files with a NUL byte or invalid UTF-8 count as binary and are skipped. docs/operations/configuration.md is checked in the form that make config-reference writes. The allowlist of the brief is extended by docs/briefs/M1-08-config-reference.md; docs/PLAN.md stays exempt as a whole file. "In backticks" (brief) means code in Markdown. The detection of code is an error class that the closing review, the red team and the re-review found three times with new instances (spans per line, backticks in other blocks, quote and list lines); it is fixed in one place with a test against the real artifact (M1-08/14). Indented code blocks cannot be told apart reliably from indented list paragraphs without a full parser. The generated block follows the chapters, which D7 checks themselves; checked as on disk, it would have kept a name that was just retired. The brief of this ticket describes W1 like PLAN and names an old name in backticks; it is outside the scope. The brief exempts PLAN as a whole; an exception only for the W1 rows would depend on the table form of PLAN, which the planning session maintains.
M1-08/5 2026-10-11 D8 reads Compose after the YAML merge keys. A service runs the server when entrypoint and command together start with serve (the image starts bothy) or with the program bothy (any path) followed by serve. The limit is the value the service sets in environment for BOTHY_SHUTDOWN__TOTAL_TIMEOUT, else the default from the schema, else the one from the row in 02; D8 does not read env_file or the configuration file. stop_grace_period follows the duration grammar of Compose (75s, 1m30s; "a string in the form of {value}{unit}", Compose file reference, Specifying durations, retrieved 2026-10-12), the limit that of the server. An unreadable value is a finding. Brief ("command starts with serve"), extended by the forms with a program path and an entrypoint and by a value set in Compose, because the first version silently checked nothing for these forms (findings of the closing review and the red team). A value with a variable (${X:-75s}) cannot be compared without .env and should not silently pass.
M1-08/6 2026-10-11 The tests in tools/configdoc/tests/ load the tool from its file instead of through import configdoc. In importlib mode pytest 9.1.1 creates the directory tools/configdoc as the namespace package configdoc (parent package of the test module), which hides the module of the same name (observed on 2026-10-11; import modes: pytest, Import modes, retrieved 2026-10-12); the brief sets the file name.
M1-08/7 2026-10-11 TestConfigKeysMatchChapterTables in internal/wire (M1-07/17) stays next to D2, D3 and D5. PLAN 3.6: no ticket deletes a test its brief does not name.
M1-08/8 2026-10-11 The tables of 02, 06, 08, 09, 12, 16 and 19 and infra/compose/compose.yaml needed no change: the check finds no rest of W1 there, and Compose already waits longer than shutdown.total_timeout. So the changelog names only the configuration reference and PLAN under "Changed". The brief asks for changes only to rows that the check finds wrong.
M1-08/9 2026-10-11 Rules of a chapter table (finding table): at most one section per chapter and exactly the heading ## <number>. Configuration values of this chapter; header Name, Default, Meaning, with Place in second position, then a separator row; every row has as many cells as the header, \| is a bar inside a cell; the cell "Default" is not empty (empty writes an empty default); the place is one of PLACES in configdoc.py. Rows may be indented by up to three spaces, rows in fenced code blocks do not count, and the section ends at the next heading of level 1 or 2. Without these rules a chapter with a slightly different heading silently dropped out of the reference, an extra cell pushed the column "Status" out of the table, and a place server withdrew a key from D6 (findings of the closing review). A closed list of places catches typos; a new program enters the list with its chapter.
M1-08/10 2026-10-11 A marker of section 4 is a line that consists only of it; exactly one start marker and after it exactly one end marker, else exit code 2. A mention in prose is no marker. Otherwise the write mode deleted text between a mention and the block, and a second, stale block passed the check (finding of the closing review).
M1-08/11 2026-10-11 D8 also reports a Compose file in which no service runs the server; test_real_repository_d8_holds_every_server_service holds that D8 checks exactly api, api-tailscale and worker in infra/compose/compose.yaml. D9 also reports an exported schema file whose variable for a key does not follow the naming rule. Oversized durations count as unreadable and are a finding instead of an abort. Without the first case and the test, D8 would silently hold less as soon as the form of the command changes; without the second, exporter and reference could name different variables (findings of the closing review).
M1-08/12 2026-10-11 The command line part of tools/configschema is written on its own (its own flag parsing, help text, writeFile with errors.Join) instead of being taken from tools/archgen; the longest common run of the two main.go files is 30 tokens (counted with go/scanner, literals treated as equal). os.Create carries //nolint:gosec for G703 with a reason. The gate "Bothy way" allows at most the share quality.sonar.* of duplicated new lines, and the duplication detection of SonarQube reports runs from 100 tokens on ("There should be at least 100 successive and duplicated tokens", SonarQube Cloud, Metric definitions, retrieved 2026-10-12); the first version had runs of 152 and 95 tokens. A shared package would have changed tools/archgen, which a parallel ticket was rebuilding. The path is the developer's -out argument; writing there is the purpose.
M1-08/13 2026-10-11 Outside the file list of the brief, chapter 23 (gate overview) names config-doc-check, and services/server/README.md (layout) names the exporter and the exported file. Fixed point 4 of the brief: a change without matching documentation is not done (findings of the closing review).
M1-08/14 2026-10-11 markdown-it-py (so far only present through bandit and rich in the tools environment) is a direct, pinned dev dependency in tools/pyproject.toml and tools/uv.lock, with a row in docs/quality/toolchain.md. The tests of tools/configdoc use the parser as a CommonMark oracle: test_code_parts_match_commonmark_on_the_real_markdown holds that code_parts finds, on every tracked Markdown file of the repository, every name in code that the parser finds (only this direction, M1-08/15), and every case of the reviewers is its own comparison for equality. The tool itself still uses only the standard library and PyYAML. Fixed point 14 of the brief applies to the tool; a test oracle is no runtime dependency. By the rule for recurring error classes, a test against the real artifact replaces patching further single cases. The dependency comes from the existing lock (same version, no new source).
M1-08/15 2026-10-11 The oracle test over the tracked Markdown files forbids only names in code that the parser finds and code_parts does not (oracle minus tool). Finding more is allowed: cells beyond the header row, which a parser after GitHub tables drops, and code spans in HTML blocks, HTML comments and indented code blocks. The reviewers' cases stay comparisons for equality. test_code_in_a_cell_beyond_the_header_is_allowed_extra_detection keeps the trigger as a fixture, and test_oracle_mismatches_report_a_span_code_parts_loses shows that a lost span is still caught. The test reads the file list with git ls-files -z. Red team of the last review round, finding 1: the row D-W-71 in docs/DECISIONS.md has a vertical bar in a code span. A parser after GitHub tables splits the row there into one cell more than the header and drops that cell ("If greater, the excess is ignored", GFM spec, Tables (extension), retrieved 2026-10-12); Python-Markdown (docs site) does not split it, and code_parts reads it. With equality the test turned red as soon as that file met M1-08, although D7 is right there. The error class of the second review round is spans that the tool loses; only this direction can hide a retired name from D7. With .split() a file name with a space or an umlaut fell apart in the list (Minor 1 of the red team).
M1-08/16 2026-10-12 Ported onto the English main: the tool reads the translated chapter tables (section "Configuration values of this chapter", header Name, Place, Default, Meaning, default words empty and no default, required, places such as Load test, Host service, Reconciliation app and Tool bothy import) and writes the columns Key, Environment, Place, Type, Default, Meaning and Status with the states active and planned (<milestone>); planned.yaml names the field milestone (the German etappe). The places Receiver and Installation script join PLACES, because main's chapters 11 and 16 name them. The rule egress. is not in planned.yaml: M1-22 already put the three egress keys into the schema, so the rule would be stale (D6). Section 4 is regenerated from main's chapters and the schema of this stack. All repository content is English (D-W-94), and the words the tool reads are listed in the glossary (section 3), so document, tool and tests change together.
M1-08/17 2026-10-12 The configuration tests of tools/api-checks (M1-03) read the row of api.deprecation_min_notice and the header of its table from the copied reference instead of writing a five-column row out; api_checks.py is unchanged. Section 4 is generated with the columns Type and Status since this ticket, and both change when tickets add keys to the schema; six tests that spelled the old row out failed. Reading the row from the reference closes that error class. Their one German sample value (halbes Jahr) is English now.

M1-09 Secret provider and bothy secrets

No. Date Ruling Reason
M1-09/1 2026-10-11 The file driver removes exactly one \n at the end and a \r directly before it; otherwise the content stays unchanged. echo value > file is the usual way to store a secret, and a line end is never part of a password; generated secrets are ASCII without a line end, and nothing changes for them.
M1-09/2 2026-10-11 A file that is empty after this rule counts as not found (not_found), like an empty environment variable. Content over maxFileSize (file.go) is a read error (read_failed). An empty password or key is never valid; the start thus aborts with a clear message instead of later in the database driver. The limit keeps a large file referenced by mistake out of memory.
M1-09/3 2026-10-11 The driver env reads from the environment that cmd/bothy read once: Options has the additional field Env for it, NewEnvDriver(environ) looks names up there, and the first entry of a name applies as with os.Getenv. Apart from helper functions this is the only addition to the interface the brief sets. The forbidigo rule (M1-07/10) and 02 "Configuration loading" item 5 allow reading the environment only in cmd/bothy. The environment of a process does not change from outside; every uncached Get therefore returns the same as os.LookupEnv.
M1-09/4 2026-10-11 The placeholder <secrets.dir> is also replaced in set values (file, environment) of *_ref keys by the effective value of secrets.dir, other placeholders are not. This replaces the last sentence of M1-07/14. The brief asks for the replacement in default and set values; Compose and operators can thus write file:<secrets.dir>/db_owner_password. More replacement would be a template language without a user.
M1-09/5 2026-10-11 A reference file: needs an absolute path in clean form (path.Clean does not change it: no . or .., no double or trailing slash) without a NUL character, in the loader as in secrets.ParseRef. bothy config check and the provider thus accept the same grammar; a reference that the check lets through does not fail only at start.
M1-09/6 2026-10-11 Errors about an invalid reference never quote its text; they name only a known scheme (file, env, azkv, awssm) and the violated rule. Valid references appear in full. As in M1-07: a secret may stand in the wrong key or the wrong column. A valid reference is configuration and also stands in bothy config print.
M1-09/7 2026-10-11 The file driver first checks with os.Stat (follows symlinks) that a regular file is present, then opens it and checks kind, mode and owner again on the opened file (File.Stat, os.SameFile) before it reads. The file cannot be swapped between check and read; a FIFO would block on open ("If O_NONBLOCK is clear, an open() for reading-only shall block the calling thread until a thread opens the file for writing", POSIX open, retrieved 2026-10-12) and is rejected before.
M1-09/8 2026-10-11 If a later read fails and a value is in the cache, the provider returns the old value, writes a log line, increments the counter once and sets the read time to now; the next attempt comes after secrets.cache_ttl. A clock that stands before the read time counts as expired. One line per TTL and reference instead of one per call; the alert BothySecretRefreshFailing needs only an increase. After a clock jump back (NTP) a value would otherwise stay valid until the clock caught up.
M1-09/9 2026-10-11 bothy secrets export backs up exactly one directory (--from, in Compose _store). The files of secrets.signing_key_dir enter the bundle with M1-32, which generates the signing keys (brief M1-32, section 2). Format: POSIX tar (PAX) with relative names, mode bits, uid and gid 0 without names, mtime 0, sorted by name, with directories; encrypted with age to every X25519 recipient, optionally with ASCII armor. Brief D8; before M1-32 there are no signing keys. A fixed format sorted by name, without owner names and times, reveals nothing about the host and is reproducible.
M1-09/10 2026-10-11 bothy secrets export rejects missing and invalid recipients (an invalid one named only by its number, never by its text) and binary output to a terminal without --armor with exit code 2, and an existing file for --output with exit code 1, which stays untouched. If the export fails, it deletes the output file it created. It never writes plaintext, not even a temporary file. Brief D8; half a bundle would look like a valid backup.
M1-09/11 2026-10-11 totp_key is a catalogue entry with the generator pending until M1-39 builds it (brief M1-39); the configuration reference names it among the generators of later milestones. Every row of the table "Secrets" is a catalogue entry (brief D6); M1-39 sets the format of the key.
M1-09/12 2026-10-11 Only regular files are allowed in --source; a symlink, a device or a FIFO aborts with exit code 2 and names the path. Files with the prefix .bothy-tmp- in target directories are leftovers of an interrupted run and are removed. secrets init runs as root over a directory of the owner and follows no link there. An aborted run should reach the same state on the next run without manual steps.
M1-09/13 2026-10-11 Before the first write, bothy secrets init checks: unknown files of the source, invalid or missing --owner (exit code 2), an --owner other than its own uid:gid without root (exit code 1) and conflicts of generated entries (exit code 1, all entries named). If one of these checks fails, it writes nothing. Every service directory of the catalogue is created, also without a file. A half reconciled volume would be harder to repair than a rejected run. Compose aborts on a subpath that does not exist.
M1-09/14 2026-10-11 W3: ruling L1 (D-L1) also covers secrets, healthcheck and restore; bothy admin ... in 08 is called bothy setup bootstrap-owner and bothy setup issue-enrollment-code. M1-37 changes 08. Recommendation of the plan (PLAN W3); 02 "Commands of the binary" already lists the commands.
M1-09/15 2026-10-11 secrets.dir is an absolute path in clean form, not /, without a NUL character and without <. The loader checks the rule for every supplied value and for the default (config.checkSecretsDir); a wrong value is a problem of secrets.dir alone, and references with <secrets.dir> then report no second one. secrets.NewProvider requires the same for Options.Dir, and secrets.ParseRef refuses / with the secrets.dir detail. The cross check secrets_dir_clean_absolute in internal/wire, built first, is dropped. bothy config check should reject what the start rejects: an empty secrets.dir turned file:<secrets.dir>/x into the valid reference file:/x, / into the invalid file://x, and a < in the value would bring back, after the replacement, a placeholder that Resolve would replace a second time. As a cross check the rule ran only after the replacement and produced a misleading follow-up problem per reference (findings of the task reviews and of the closing review).
M1-09/16 2026-10-11 bothy secrets init holds an exclusive, non-blocking lock (flock) on the target directory for the whole run; a second run on the same target fails with exit code 1. Platforms without flock reject init. Two concurrent runs (for example docker compose run secrets-init ... during up) deleted each other's temporary files and could replace a generated value with a second one, which the brief forbids (finding of a task review).
M1-09/17 2026-10-11 The fingerprint of secrets init (twelve hex characters of an unsalted SHA-256) does not count as free of secrets: it confirms a guessed weak supplied value. The output, which the log of secrets-init keeps, does not belong in public places; the README of stack says so. The brief sets format and length (16 5.3 item 5); generated values have 256 bits of randomness and are not affected.
M1-09/18 2026-10-11 bothy secrets init with an empty --target or --source and bothy secrets export with an empty --from or --output are usage errors (exit code 2). As M1-07/21: an empty path from an empty variable would silently mean "no source" (a restore with --adopt-source would generate new secrets instead of taking over the bundle) or standard output instead of a file. For the same reason --adopt-source without --source is a usage error: without a source the flag has no effect (finding of the closing review).
M1-09/19 2026-10-11 A second --owner for the same service is a usage error (exit code 2). The command does not guess which of two owners is meant.
M1-09/20 2026-10-11 Usage errors that only stack detects (every error with stack.ErrUsage: an unknown or non-regular file of the source, a path of --source or --from that is missing or not a directory, --owner for an unknown service, a missing --owner for server or migrate, a missing or invalid recipient, binary output to a terminal without --armor) give exit code 2 with exactly one line bothy secrets <subcommand>: <error> without the help text; errors of the command line itself (unknown flag, invalid flag value, missing --target or --from, empty path, unexpected argument, missing or unknown subcommand) also show the help. The line names the file, service or recipient; the help would hide it and does not help, because the form of the call was right (deviation from the plan, which always showed the help).
M1-09/21 2026-10-11 bothy secrets without a subcommand is a usage error like bothy and bothy config without a command (exit code 2, help on standard error); help, -h and --help print the help with exit code 0. Apart from the paths of --target, --source, --from and --output, which messages name, no message of bothy secrets repeats text of the command line: an unexpected argument, an unknown flag or an invalid flag value appear only with their position (argument 3 ...), an unknown subcommand without its name, and --owner accepts only service names of lower-case letters, digits and -, so that an unknown service is never other text. A call that does nothing must not count as success in a script with an empty variable. An age identity pasted without --age-recipient (AGE-SECRET-KEY-1...) must reach the terminal or a log as little as a rejected recipient, which stack.Export names only by its number (M1-09/10).
M1-09/22 2026-10-11 bothy config check warns, with and without --secrets, on standard error about every key *_ref whose effective reference uses env:; the exit code stays unaffected. Brief D2: env: is allowed but not recommended (16 5.2); the check without resolving is the usual call and should show the hint as well (finding of a task review).
M1-09/23 2026-10-11 The last line of secrets init is the export command that can be called, with the _store of the run: back up the secrets: bothy secrets export --from <target>/_store --age-recipient <age public key>; a path that is not a simple shell word stands in single quotes. This deviates from the wording of brief D4. D8 makes --from mandatory; the line of the brief gave exit code 2 when copied and tempted to export a wrong directory (finding of the closing review).
M1-09/24 2026-10-11 secrets.cache_ttl is at least 1s; Options.CacheTTL may be 0 for tests. With 0 every call would read the secret, and while a later read fails, every call would write a log line and increment the counter instead of once per TTL (finding of the closing review).
M1-09/25 2026-10-11 secrets init rejects supplied files of file and pattern entries without a value (empty or only a line end) with exit code 2 and names all of them; such a value in _store aborts with exit code 1. Files below a directory entry (offsite/) may be empty. M1-09/2 also applies to the services that read these files themselves (database, backup, Grafana); an empty value in _store would otherwise stay kept forever (finding of the closing review).
M1-09/26 2026-10-11 secrets export bundles only files with a catalogue entry; another file in --from is a usage error (exit code 2) that names all of them before the output is created. The bundle is the source of the restore, and secrets init --source rejects every other file; an export of the root of the volume or of a _store with a foreign file would otherwise fail only in an emergency (finding of the closing review).
M1-09/27 2026-10-11 The log line of a failed later read names only error.type, not the reference; bothy config check --secrets shows which reference fails (runbook bothysecretrefreshfailing in docs/observability/alerting.md). Waiting callers of the same reference wait for the running read; the context is checked at the call. Log attributes come only from the list in docs/observability/signals.md 5.3, which knows no reference, and brief D3 names only error.type. The drivers in M1 read locally; the network drivers of a later milestone bring a read that does not hold up waiting callers (findings of the closing review).
M1-09/28 2026-10-11 secrets init gives the root of the volume the running user as its owner, not only the mode 0700; .gitignore excludes infra/compose/secrets/, and case B in disaster-recovery.md says to delete the decrypted generated files after the takeover. A foreign owner of the root could read the copies of the services without --owner and set the mode back; a decrypted bundle in the working tree must never get into a commit (findings of the closing review).
M1-09/29 2026-10-11 As an existing target, secrets init accepts only an empty directory (except for lost+found) or a directory with _store; otherwise it is a usage error (exit code 2) before anything changes. A mistyped --target (for example /var/lib) would otherwise give the directory to the running user, set 0700 and empty every subdirectory named like a service (postgres, grafana, ...); reproduced in the red team. lost+found comes with a file system of its own for the volume.
M1-09/30 2026-10-12 Ported onto the English main: the catalogue test reads configuration.md section 5 "Secrets" with the English origin words generated, supplied and generated or supplied; the module register gets the platform package secrets with the vendor groups age and otel, and go.mod takes filippo.io/age and the OpenTelemetry API and metric SDK at the versions of the ticket, followed by go mod tidy; the generated files are regenerated on this stack. All repository content is English (D-W-94); the vendor groups of later tickets (oapi, openapi) are not part of this ticket.

M1-17 Server interface

No. Date Ruling Reason
M1-17/1 2026-10-11 P-04 is implemented with a pre-filtered copy of the spec, for every enable list, also the empty one: paths keeps only the methods of enabled operations, paths without a method are dropped, webhooks is missing, components stays complete; oapi-codegen runs with skip-prune: true on this copy. include-operation-ids is not used. Probe on 2026-10-11 with oapi-codegen v2.8.0 against the fixture: include-operation-ids: [getThing] generates only getThing; include-operation-ids: [] generates all three operations, because the filter applies only to a non-empty list (len(opts.OutputOptions.IncludeOperationIDs) > 0 in oapi-codegen v2.8.0 pkg/codegen/filter.go, retrieved 2026-10-12). The empty list of milestone M1 would thus register every operation; the copy is one mechanism for both cases. TestGenerate_FilteredSpecKeepsAllComponents and TestGenerate_FilteredSpecDropsWebhooksAndDisabledMethods hold the filter.
M1-17/2 2026-10-11 W2 checked: docs/api/openapi.yaml has no health operation. The generator rejects every operation whose ID names health, liveness or readiness or whose path has a segment health, healthz, livez, liveness, readyz or readiness; TestRealSpec_NoHealthOperations runs on the real spec. Ruling L2 and 06 R-API-16: health lives on the internal listener. getLiveness and getReadiness were removed before this ticket; the check prevents them from coming back. getStatsLive (/stats/live) is no probe operation, so live alone does not count.
M1-17/3 2026-10-11 W6 checked: servers of openapi.yaml is exactly [{url: /api/v1}] (a check of the generator, otherwise a finding), and the generated registration puts routes.BaseURL (/api/v1) in front of every path (build.gen.go); TestAZP1_FixtureRoutesMatchEnableList compares the patterns that the generated std-http server registers with Operation.Pattern, and TestFixtureRoutes_Serve calls /api/v1/things/... through a real ServeMux. 06 "Overview" applies (revision of the core contract); spec and server cannot drift apart, because both places are checked.
M1-17/4 2026-10-11 later in services/server/api/operations.yaml is filled once by the rule of the brief: (1) if a ticket row of a table in docs/PLAN.md names the operationId in backticks, the first such row in document order applies (its ID is ticket, its prefix milestone); (2) otherwise the table of the brief; (3) no operation was left without a milestone. setAgentToolClass is in both sources; by rule (1) M1 with M1-51 applies, not M2 from the table. After that the list is the source; the generator only checks it. The row M1-51 in PLAN names setAgentToolClass among the operations M1-51 builds; by its wording the table of the brief is only the fallback. The throwaway script lived outside the repository.
M1-17/5 2026-10-11 api/routes has a generic core Table[S] (table, catalogue, glue) with NewTable; the package functions Enabled, ByPattern, ByID, SpecCatalog, Verify and Build use the table from the generated files of this package. The fixture under api/routes/internal/fixture/routes gets the same three generated files, with routes. in front of the core types (flag -core-pkg), and a hand-written file fixture.go that binds them to the core. The fixture must run through the same code of Build, Verify and Recorder as the real surface; a package cannot import itself, so the generator qualifies the types outside the core. NewTable also serves later test helpers with operations of their own (M1-18 httpservertest).
M1-17/6 2026-10-11 api/routes/routestest gives tests outside api/routes the fixture surface (operations, catalogue, Build with a stub). Only _test.go files import it; TestWire_NoFixtureInProductionGraph checks with go list -deps that internal/wire uses neither routestest nor a fixture package. Go's rule for internal forbids internal/wire to import api/routes/internal/fixture/...; the start test of internal/wire needs generated routes with operations, though. Test helpers live in the tree of the component whose types they build (02 "Code layout").
M1-17/7 2026-10-11 internal/wire gets Role (api, worker, all), Mux, the empty composite Handlers and MountRoutes(role, mux, handlers, cat, log) as step 7 of the start. For worker it mounts nothing; on a finding it logs route check failed at level error with the whole error, mounts nothing and returns the error. Before the real mux it registers the routes on a throwaway ServeMux, so that a pattern conflict is an error instead of a panic in the middle of mounting. M1-11 builds bothy serve itself and the public listener; it calls this step and turns the error into exit code 1 before readiness. M1-11 does not depend on M1-17 and builds serve only after it; its brief names the routes as an extension point of wire.Serve. The start abort is therefore proven at the level of the start step (TestServe_AbortsOnUnknownAction), end to end through bothy serve only with M1-11. Role is created here, because the step distinguishes roles; M1-11 takes over the type.
M1-17/8 2026-10-11 catalog.gen.go holds per action of docs/spec/actions.yaml the ID, the permission (verbatim) and the class; SpecCatalog does not know actions with the permission system. 05 rule 10: system actions cannot be reached through the API; a route with such an action must fail the start check even when the generator is bypassed. M1-25 replaces the catalogue with the closed catalogue of authz.
M1-17/9 2026-10-11 The register gets the component api: {vendors: [oapi, openapi, yaml]} with the vendor groups oapi (runtime of oapi-codegen), openapi (kin-openapi) and yaml. That the packages of api may import each other (api/routes uses api/rest, the fixture the core) is handled by tools/archgen after M1-05/14 with api: [api]; kernel-ports gets no self entry after M1-05/15. Version after the rebase onto M1-05 (M1-17/26). go-arch-lint v1.19.0 rejects an import between two packages of the same component as long as it does not name itself (probe on 2026-10-11), and api/routes must import api/rest as soon as an operation is enabled. The first version of this ruling changed tools/archgen itself for this; M1-05 fixed the same error class at the same place (M1-17/26). depOnAnyVendor stays false.
M1-17/10 2026-10-11 The Go coverage of the module does not count lines of generated files (*.gen.go); the recipe coverage filters them out of its copy of the profile like test/ and perf/, and coverage.out stays unchanged. With rest.gen.go (models of all schemas) the total would be 32.2 % instead of 93.3 % (measured on 2026-10-11 on the state of this ticket). Generated code is recognizable by .gen.go after M1-05/7 and already excluded by Sonar; the golden test and make codegen-check hold it. The threshold stays the same.
M1-17/11 2026-10-11 The generator resolves the pinned oapi-codegen with go tool -n oapi-codegen and starts the binary in the temporary directory with -config <api/rest/oapi-codegen.yaml>; the directory gets a go.mod with the go directive of the module. It reads the generated file under the name from output of the configuration, which must be a plain file name. oapi-codegen v2 ignores -o when a configuration is given and writes output relative to its working directory (probe on 2026-10-11); in the module it would thus run into the source tree. Without a go.mod in the working directory it warns, because it cannot check the Go version for std-http patterns; the copied directive makes the check work. The configuration stays exactly the one of the brief.
M1-17/12 2026-10-11 With an empty enable list the generator writes type Server = any and a register function without effect; build.gen.go then does not import api/rest. oapi-codegen v2.8.0 generates ServerInterface, StrictServerInterface (empty), HandlerWithOptions and NewStrictHandlerWithOptions also for zero operations (probe on 2026-10-11); the route files still depend on none of these symbols, so the empty case stays independent of future versions.
M1-17/13 2026-10-11 Beyond the rules of the brief, the generator rejects: unknown keys in the list, a missing or duplicate operationId, every key of a path except the lower-case methods, summary and description (for example $ref, parameters, servers, connect or GET), servers on an operation, a request body with more than one media type or another one than application/json, multipart/form-data, application/octet-stream, Idempotency-Key or If-Match more than once on an operation, an empty security requirement ({}), ETag on a status range (2XX), unknown response keys, YAML merge keys (<<) in the spec, the class none under paths (M1-17/20) and security requirements of its own on an enabled operation (M1-17/19); a spec that the loader of oapi-codegen does not load (for example an unresolvable reference) is a finding as well. Each of these forms would make the metadata of the route table silently wrong or make the copy for oapi-codegen filter wrongly; fail closed instead of guessing. Of these, the real spec uses only security requirements of their own, for getPushPreview (only DPoP) and for downloadFile and downloadThumbnail (in addition fileTicket); all three stand under later.
M1-17/14 2026-10-11 The example of the incoming webhook token in docs/api/openapi.yaml is now iwt_EXAMPLEStGXR8Z5jdH (three places). CONTRIBUTING.md asks for EXAMPLE in examples of credentials; in the generated Go comment gitleaks reported the old value as a generic API key. Fixing the source keeps the rule instead of needing an exception in .gitleaks.toml.
M1-17/15 2026-10-11 A directory named gen counts as generated neither for sonar.exclusions nor for the SPDX check in tools/ci/license_check.py; generated code stays excluded through **/*.gen.go, **/*_gen.go, **/sqlcgen/** and **/generated/** (Sonar) or the file patterns of the license gate. The generator under services/server/api/routes/gen (path from the brief) is hand-written and belongs in the analysis and the SPDX check; the red team found the gap in the license gate (finding 3 of the first round).
M1-17/16 2026-10-11 rest.gen.go stays one file, as the brief asks for it (output: rest.gen.go). It is below repo.max_file_kb today; every enabled operation adds server code, and with all operations of the spec the file would exceed it (probe on 2026-10-11). The ticket that reaches the limit decides on the split (for example models and server in separate runs); until then the hook check-added-large-files holds the limit.
M1-17/17 2026-10-11 routes.Options with nil uses the error handlers of oapi-codegen, which write the error text with 400 or 500. The brief gives the problem writers to M1-18; today no operation is enabled, so no request reaches these handlers.
M1-17/18 2026-10-11 The generator derives the checks and metadata per operation from the document that the kin-openapi loader (v0.142.0, the same version with which oapi-codegen v2.8.0 loads) reads from the copy without webhooks; the YAML node tree serves only the filter, the list and the catalogue. It recognizes Idempotency-Key and If-Match by the resolved header, whether declared through a component, a chain of components or directly; If-Match with required: true is IfMatchRequired, otherwise IfMatchOptional. kin-openapi becomes a direct dependency of the generator (MIT), vendor group openapi of the component api. Red team, first round, finding 1 (Major): the own node walker followed reference chains only one level, saw no merge keys and read required only as the text true, while oapi-codegen resolves all of that; the table would silently have reported IdempotencyKey: false, IfMatchNone or ETagStatuses: nil where the server binds the header. The same class is fixed in one place: table and server come from the same model. TestOperationMetadata_FollowsWhatOapiCodegenSees holds chains, required: True and directly declared headers; the generator rejects merge keys (M1-17/13), because the filter works on the node tree.
M1-17/19 2026-10-11 An enabled operation with security requirements of its own (neither security: [] nor equal to the root) is a finding of the generator; under later it is allowed. By the brief the route table knows only Public; an operation that accepts, for example, only DPoP or also the file ticket would look to the HTTP chain like one with the root security (review finding CR-1). The ticket that enables getPushPreview, downloadFile or downloadThumbnail first extends the table by the requirements.
M1-17/20 2026-10-11 The class none under paths is a finding of the generator, and SpecCatalog does not know actions with the class none. Only system actions and internal delivery checks carry none (05 "Rate-limit classes"), which have no REST route (06 "Authorization in the API", item 7); such a route would run without throttling (red team, first round, finding 2).
M1-17/21 2026-10-11 The license gate requires, in every Go file that counts as generated by its name (*.gen.go, *_gen.go, *.pb.go), the marker // Code generated ... DO NOT EDIT. before the package line. Coverage (M1-17/10), Sonar and the SPDX check recognize generated code by its name; without a check of the marker, a hand-written file with such a name would silently drop out of all three (red team, first round, finding 4).
M1-17/22 2026-10-11 The generator writes every output first to a temporary file next to the target and renames all of them only when all are written; an abort by SIGINT or SIGTERM ends oapi-codegen through the context and removes the temporary directory. wire.MountRoutes also turns a conflict with a pattern that already sits on the public mux into an error instead of a panic. Review findings CR-4 and CR-5 and red team, first round, finding 6. A failed write step leaves the checked-in files in place; on a conflict on the public mux the start fails before the server is ready.
M1-17/23 2026-10-11 wire.MountRoutes gets the composite of the module handlers as a parameter and builds from it; it does not create a zero value of Handlers itself. Review finding C-1: the compiler checks only that the methods are complete; an embedded handler without its dependencies would pass every check and panic only on the request. The start (M1-11) builds the handlers with their dependencies and passes them.
M1-17/24 2026-10-11 The vendor groups yaml and openapi apply to the whole component api, but only the generator api/routes/gen needs them; TestWire_NoFixtureInProductionGraph holds with go list -deps that neither the generator nor kin-openapi ends up in the server and that api/rest and api/routes do not use go.yaml.in/yaml (made precise by M1-17/28). The module register knows only one component for api (api/**); a component of its own for the generator would need a new field in the register, which 02 does not provide. The test covers the path into the binary, because internal/wire is the only production user of api (review findings C-4, G-1).
M1-17/25 2026-10-11 The generator loads the filtered copy for oapi-codegen with the same loader and requires that its operations (operationId, method, path) are exactly the enabled ones of the route table; it checks the keys of a path on the unfiltered node tree. When searching for merge keys it follows no YAML alias. Re-review round 1: after M1-17/18 the filter saw a method connect, a method key in another spelling and a path item from $ref differently than kin-openapi, and a self-referencing anchor made the search for merge keys not end. The check of the filtered copy closes the class "filter and loader see different operations" in one place; tests: TestCheckFiltered_MatchesTheTable, cases in TestLoadInputs_FailsClosedOnUnsupportedForms, TestLoadInputs_SelfReferencingAnchorFailsPromptly.
M1-17/26 2026-10-11 Rebase onto the final state of M1-05 and M1-07: in tools/archgen (generator, tests, golden and skeleton files), in the section "go-arch-lint" of 02 and in the archgen part of .go-arch-lint.yml the state of M1-05 applies. The archgen change of M1-17 (self entries from the first version of M1-17/9, among them kernel-ports: [kernel-ports]) and TestArchLintComponentImportsItself with its test data are dropped. Of M1-17, the component api and the vendor groups oapi and openapi stay in the register; .go-arch-lint.yml is generated from it again with go generate ./tools/archgen/.... M1-17/9 and services/server/CHANGELOG.md (entry on tools/archgen under Changed removed) follow this state. M1-05 fixed the same error class after M1-17 branched off (M1-05/14, finding of the red team), with a red-team verdict JA; two versions of the same rule cannot both apply. The self entry of kernel-ports would contradict M1-05/15, and no M1-17 package needs it (red team of M1-17, section on the overlap with M1-05). TestArchLintComponentImportsItself failed on the test layout of M1-05 (Minor 9 of the same red team); TestArchLintChapter02Layout holds the same edges. After the rebase make arch reports OK - No warnings found.
M1-17/27 2026-10-11 Since M1-07, golangci-lint also checks generated files (generated: disable, M1-07/10) and the hand-written code with gocognit and goconst (M1-07/23). For the generated code of this ticket only staticcheck ST1005 is left out, for the two rest.gen.go. Five functions of the generator (filterSpec, checkListAgainst, readOperations, parameters, etagStatuses) are split into helper functions, and api/routes writes none and system through constants; the behaviour stays the same. M1-07/10 asks the ticket with generated Go code to leave out, for its files, only the linters that the code cannot satisfy, never forbidigo: oapi-codegen writes error texts with a capital first letter. Probe on 2026-10-11: on the state after the rebase golangci-lint reported 11 findings (gocognit 5, goconst 2, misspell 3, staticcheck 1), afterwards 0 issues.; one mutant each shows that staticcheck SA4009 in rest.gen.go, ST1005 in hand-written code and misspell in routes.gen.go are still reported. After the rework go generate ./... writes byte-identical files, and the tests of the generator stay green. The German version also left misspell out for the generated action catalogue, because it copied the German permission column (Autor); with the English chapter 05 that exception is not needed (M1-17/32).
M1-17/28 2026-10-11 TestWire_NoFixtureInProductionGraph forbids go.yaml.in/yaml in the graph of api/rest and api/routes instead of in the whole graph of internal/wire; the generator, the fixture packages, routestest and kin-openapi stay forbidden in the graph of internal/wire. Since M1-07, internal/wire uses the platform config, which reads YAML on purpose (vendor group yaml of the platform, M1-07/4); after the rebase the test failed on that, not on the generator. It now checks the intention of M1-17/24, that the component api uses the group yaml only in the generator, on the packages from which the server builds the routes. A mutant with a YAML import in api/routes and one with kin-openapi in api/rest fail.
M1-17/29 2026-10-11 A change to docs/spec/actions.yaml selects the component server in the CI path filter (GATE_COMPONENT_INPUTS in tools/ci/components.py: files outside the directory of the component from which its generated code is built). The flow of a catalogue change in tools/catalog/README.md names generating the server again. Red team of M1-17, Minor 1: since M1-17, services/server/api/generate.go generates the route table and the action catalogue from actions.yaml; the filter selected no component on a pure catalogue change, codegen-check skipped the server, and the drift showed only in the next server PR. Only the server reads the file (the clients read docs/api/), so not all components as for docs/api/. M1-25 (constants from actions.yaml) uses the same entry.
M1-17/30 2026-10-11 No literal with more than five characters stands three times in a hand-written file of api/: the generator names every flag once in a table, filterSpec puts the prefix filter: in one place, and the list names its sections through sectionEnabled and sectionLater. The behaviour stays the same; TestRun_ExitCodes now also checks that the message names the first missing required flag. Since M1-17/15 Sonar analyses the generator, and the rule go:S1192 of "Sonar way" reports a literal that stands three times in a file; the gate "Bothy way" allows no new finding, and CI waits for the gate. goconst does not see call arguments and elements of composite literals (ignore-calls: true, M1-07/23). A scan by the rule (literal over five characters, three times in one file, without tests, imports and struct tags) found seven such literals in main.go, filter.go and list.go in the closing review, afterwards none; Sonar itself did not run for lack of SONAR_TOKEN.
M1-17/31 2026-10-11 A directory named gen does not count as generated for the gocognit and goconst exception in services/server/.golangci.yml either: the rule for generated code names *.gen.go, *_gen.go, *.pb.go and the directories sqlcgen/ and generated/. tools/ci/tests/test_server_lint_config.py no longer lists **/gen/** as an entry for generated Go code and checks gen/x.go, internal/gen/x.go and api/routes/gen/x.go as product code that neither the lint exception nor sonar.exclusions may leave out. M1-07/23 and M1-07/28 thus apply without the directory gen/. After M1-17 (M1-17/15 removes **/gen/** from sonar.exclusions) met M1-05-07-minors (M1-07/28 exempts gen/ from both linters), Sonar analysed the generator in api/routes/gen but the lint rule left it out; make check failed in test on seven cases of test_server_lint_config.py, exactly the case M1-07/28 predicted. A directory name does not make code generated (M1-17/15), generated Go files end in .gen.go (M1-05/7), and the lint gate must not be looser than the analysis (M1-07/23). Probe on 2026-10-11: with the new rule golangci-lint reports 0 issues. on the module, so the generator needs no change; a mutant under api/routes/gen with a function of cognitive complexity 30 and a literal written three times is reported by gocognit and goconst, and with the old rule by neither.
M1-17/32 2026-10-12 Ported onto the English main: the enable list names its field milestone (the German etappe), also in the generator, its messages and the negative fixtures bad-milestone and ticket-milestone-mismatch; renewInvocationToken, which main's openapi.yaml has and the German spec did not, stands under later with milestone M2 and ticket M2-09, the first PLAN row that names it (rule of M1-17/4); the generated action catalogue copies the English permission column, so the misspell exception for catalog.gen.go is dropped (golangci-lint reports 0 issues. without it); go.mod takes kin-openapi, the oapi-codegen runtime and the tool directive at the ticket's versions, followed by go mod tidy, and every generated file is generated again on this stack. All repository content is English (D-W-94); the generator fails on an operation in neither section, so the new operation of main had to enter the list.
M1-17/33 2026-10-12 Two findings of SonarQube Cloud on this ticket are suppressed on their lines with NOSONAR: the single-method interfaces routes.Catalog and wire.Mux keep their names, and the route generator starts go from PATH. As in M1-05/26, Catalog and Mux name what the port is (the action catalogue, the public mux) and are the names their callers use; Effective Go calls the -er form a convention (Effective Go, Interface names, retrieved 2026-10-12). The generator is a development tool that go generate starts with the go command of the developer's toolchain; it has to ask exactly that go for the pinned tool (go tool -n) and the module (go env GOMOD), and a fixed path would pick another toolchain. Nothing of it runs in the server.

M1-12 PostgreSQL access, migrations and schema version

No. Date Ruling Reason
M1-12/1 2026-10-11 Role passwords reach PostgreSQL only as SCRAM-SHA-256 verifiers computed in Go: 4096 iterations, 16 random bytes of salt (scramIterations, scramSaltBytes in scram.go), sent with the simple protocol so pgx never caches the statement. 4096 is the default of scram_iterations (PostgreSQL, scram_iterations, retrieved 2026-10-12) and the minimum iteration count of RFC 7677 section 4 (retrieved 2026-10-12), so a login costs what it costs with a password PostgreSQL hashed itself; 16 bytes is the salt PostgreSQL generates (SCRAM_DEFAULT_SALT_LEN in scram-common.h, retrieved 2026-10-12).
M1-12/2 2026-10-11 Role passwords must be non-empty printable ASCII (0x20 to 0x7e); the error names the role, never the value. For those characters SASLprep (PostgreSQL, libpq) and OpaqueString (pgx) change nothing, so every client derives the key the verifier was computed from; secrets init generates base64url values.
M1-12/3 2026-10-11 pg.Config renders every libpq setting in the connection string, also empty ones (passfile, sslcert, sslkey, sslrootcert, channel_binding, require_auth and the rest), drops fallbacks, Kerberos names and the parsed password afterwards, and sets pgx's exec mode, cache sizes and the pool lifetimes to their defaults, so a libpq service file (PGSERVICE) cannot change them. pgx reads PG* variables, ~/.pgpass, ~/.pg_service.conf and ~/.postgresql/; 02 "Configuration loading" item 5 allows only cmd/bothy to read the environment, and a stray PGSSLMODE or root.crt must not change a connection.
M1-12/4 2026-10-11 A zero deadline in pg.Config switches it off as in PostgreSQL; the configuration keys have 1ms as minimum and 24d as maximum (database.statement_timeout, lock_timeout, idle_in_tx_timeout, migrate.lock_timeout). Deadlines are sent in whole milliseconds, rounded up. bothy migrate needs statement_timeout = 0; the server never may (02 "Deadlines"). PostgreSQL stores the three as int32 milliseconds; rounding up keeps a positive deadline below 1ms from becoming 0.
M1-12/5 2026-10-11 Every connection starts with default_transaction_isolation = 'read committed' and TimeZone = 'UTC'; Begin sets READ COMMITTED when the options leave the level empty. 03 "Principles" item 11 and 16 7.2 fix the level, and goose writes its tstamp with now() in the session time zone.
M1-12/6 2026-10-11 Acquire, Begin (acquire and BEGIN together) and Ping wait at most database.acquire_timeout. When that deadline passes while every connection is acquired, the cause is db_pool_exhausted; when the database does not answer or refuses the login, dependency_unavailable; both have code unavailable. An expired request context is deadline_exceeded, a canceled one comes back as the context error. 02 "Error kinds" names db_pool_exhausted for "no connection was free"; a database that is down is a different operator signal. A client that went away is no server error.
M1-12/7 2026-10-11 WaitReady and pg.Connect retry with exponential backoff from backoffBase to backoffCap (pool.go) and full jitter; each failed attempt logs database not reachable yet, retrying with server.address, server.port and error.type (SQLSTATE, secret error code, timeout or connect), never the error text. The final error names host and port and wraps the last real cause; an attempt cut short by the end of the wait does not replace it. Agreed with M1-10 (log keys of the catalogue, no foreign error text in logs, signals.md "Logs"); the final error goes to the operator on stderr.
M1-12/8 2026-10-11 The tracer of the pool wraps otelpgx: it replaces PASSWORD '...' literals in the statement text with PASSWORD '[redacted]', and the error of a failed statement, batch, copy or prepare with a PostgreSQL error that keeps severity and SQLSTATE only. otelpgx writes err.Error() into the span status and an exception event, and PostgreSQL messages quote parameter values (invalid input syntax for type integer: "<value>"), which the parameter option alone does not keep out; the role statements of the bootstrap carry the SCRAM verifier as a literal, enough for an offline guess.
M1-12/9 2026-10-11 otelpgx v0.13.0 has no option for semantic conventions and emits only the current names: db.system.name, db.namespace, db.operation.name, db.query.text, db.collection.name (COPY spans only), plus server.address, server.port, user.name, pgx.rows_affected, pgx.sql_state and pgx.prepare_stmt.name (otelpgx v0.13.0 tracer.go, retrieved 2026-10-12). signals.md "Database" asks to check the installed version; the span allowlist of M1-10 drops user.name and pgx.* before export.
M1-12/10 2026-10-11 The migrator uses goose's PostgreSQL session locker with a poll every lockPollSeconds seconds for up to lockPollAttempts attempts (migrator.go), a fresh locker per migrator, and the global goose registry switched off. Two migrate up apply each migration once; goose keeps the retry state of its polls in the locker.
M1-12/11 2026-10-11 The schema version is max(version_id) of the applied rows of goose_db_version, 0 without the table; CheckSchema and Migrator.Status read it with their own queries. goose's GetDBVersion and Status take the migration lock and create the version table; a status or a start check must only read.
M1-12/12 2026-10-11 Against a database newer than the binary, migrate up runs neither its steps nor goose; it prints schema is newer than this binary; nothing to do and exits 0. upgrade.md "Rollback": the older binary must not touch a schema of the newer one, also not through River or the instance row.
M1-12/13 2026-10-11 Output of bothy migrate: applied <version> <name> with the version as a number and the slug of the file name, then database version <n>, applied <k>; status lines <version> <name> <applied\|pending> <applied_at\|-> with the time in RFC 3339 UTC; bootstrap: roles and grants are in place after --bootstrap; migrations ok after validate. A configuration that does not load or bind is exit code 2, every runtime failure 1. Stable lines for scripts on stdout (02 3.3); 02 3.3 counts configuration errors as exit code 2.
M1-12/14 2026-10-11 Validate parses each file with goose's own parser through a provider over a driver that refuses every statement, requires -- +goose Up, and refuses -- +goose ENVSUB in any case and after any number of --, as goose reads it. goose v3.28.0 has no public parser (its SQL parser is the internal package internal/sqlparser, goose v3.28.0, retrieved 2026-10-12); with ENVSUB a migration would read the process environment at migrate time.
M1-12/15 2026-10-11 BootstrapRoles runs in one transaction under a transaction advisory lock and is retried once when a concurrent bootstrap created a role first (42710, 23505); BootstrapDatabase refuses a connection that is not on the named database. Two migrate up --bootstrap must not fail each other; the schema statements act on the current database only.
M1-12/16 2026-10-11 bothy config check --secrets prints secret <key>: skipped (read only by bothy migrate up --bootstrap) for the migrate.* references and does not resolve them. The api and worker containers do not get the superuser and owner passwords; the runbook command would otherwise fail in every server container.
M1-12/17 2026-10-11 The module register gains the optional top-level section migrations: {used_by: [...]}: the package migrations is a component that uses nothing, which wire, cmd and the listed platform packages may use (02 7.1). The brief asks for a component for the package; 7.1 allows new entries only, and the section is optional, so registers without it stay valid.
M1-12/18 2026-10-11 pgtest names its containers bothy-pgtest-<hex> and labels them org.bothy.test=pgtest; Ryuk is disabled by os.Setenv in Main before the first testcontainers call. A session-specific name would need an environment read in a product package (forbidigo); the label finds leaked containers. testcontainers has no programmatic switch for Ryuk.
M1-12/19 2026-10-11 pgtest keeps one template per migration set (hash of names and contents), closes it for connections after migrating, clones it per New, and gives tests a pool with the limits testMaxConns, testAcquireTimeout, testStatementTimeout, testLockTimeout and testIdleInTxTimeout (pgtest.go). A template whose migration fails is dropped again, so a retry reports the migration error, not "database already exists". Tests that change a role password do not run in parallel and restore it. Templates make each database cheap; the deadlines leave room on a shared machine under the race detector; roles are cluster-wide.
M1-12/20 2026-10-11 TestPrivilegesAfterAllMigrations compares the rights of bothy_app on every table in public with the list appPrivileges (all eight table rights including MAINTAIN); a table without a row fails. 16 7.1 rule 3 asks for an expectation list per table; PostgreSQL 17 added MAINTAIN (PostgreSQL 17 release notes, retrieved 2026-10-12).
M1-12/21 2026-10-11 database.tx_retry_max and database.listen_reconnect_backoff_cap are registered now, although M1-13 and M1-16 read them. The brief asks for all database.* keys of 02 and 16 in this ticket.
M1-12/22 2026-10-11 services/server/osv-scanner.toml overrides the license of github.com/opencontainers/go-digest 1.0.0 (pulled in by testcontainers for pgtest) to Apache-2.0. deps.dev reports CC-BY-SA-4.0 (deps.dev, go-digest v1.0.0, retrieved 2026-10-11), which the package's LICENSE.docs grants only for its README and CONTRIBUTING; the code is Apache-2.0 (LICENSE). docs/quality/licenses.md item 3 asks for exactly this per-package override with reason.
M1-12/23 2026-10-11 golang.org/x/crypto 0.56.0, github.com/moby/go-archive 0.3.0 and github.com/moby/sys/user 0.4.1 instead of the versions the new dependencies select. make osv reports GO-2026-6354, GO-2026-6355, GO-2026-6253 and GHSA-mjcv-p78q-w5fw for the selected versions (run on 2026-10-11; each entry at https://osv.dev/vulnerability/<id>); the fixed versions build and pass the tests.
M1-12/24 2026-10-12 Ported onto the English main: comments and messages name the English chapters and headings; the module register keeps M1-17's component api next to the new section migrations; the rules database., migrate. and startup. leave tools/configdoc/planned.yaml, because M1-12 puts every database.* and migrate.* key of milestone M1 and startup.dependency_wait into the schema and D6 would report them as stale; the archgen change uses the component constants of M1-05-07-minors (migrationsComponent joins them); one non-ASCII test password uses a non-German letter; generated files are generated again on this stack. All repository content is English (D-W-94); the planned rules follow the schema, as M1-08/3 asks.

M1-10 Logging and telemetry foundation

No. Date Ruling Reason
M1-10/1 2026-10-11 D1: with telemetry.enabled = false, with OTEL_LOGS_EXPORTER=none or with telemetry.logs.exporter = stdout the server logger writes JSON to stdout even when telemetry.logs.exporter = otlp; there is no configuration in which logs go nowhere. Brief D1: logs are never silently lost. An operator who turns telemetry off still needs the logs of the process (TestDisabledTelemetryKeepsStdoutLogs).
M1-10/2 2026-10-11 D3: traces and logs each have one in-flight budget (OTEL_BSP_MAX_QUEUE_SIZE, OTEL_BLRP_MAX_QUEUE_SIZE, default 2048) across the front queue, the SDK batch processor and the running export. An item takes a slot on entering the front queue and returns it after its export; an item without a slot is counted in bothy.telemetry.dropped{bothy.signal}, and every item of a failed export (spans, log records, metric data points) is counted too. The SDK queues are as large as the budget, so neither the blocking span batch processor nor the log batch processor (no blocking option, it drops its oldest record on overflow) ever overflows. Not counted: items still queued when Shutdown reaches its deadline; a failed last metric export at Shutdown is counted but never exported. A front queue alone is not enough: the SDK batch processors collect up to one export batch before they export, so with a collector that never answers they would hold that many items more than the front queue before anything was counted, and the log batch processor would then drop silently. One budget per signal bounds memory and makes every loss before the deadline visible.
M1-10/3 2026-10-11 Deps has a fifth field Env (the environment as cmd/bothy read it). Setup reads from it the protocol variables (grpc default, http/protobuf; http/json has no Go exporter and is a start error), OTEL_{TRACES,METRICS,LOGS}_EXPORTER (otlp or none, anything else a start error) and the two queue sizes (a whole number of at least 1). Endpoint, headers, timeouts, TLS and OTEL_RESOURCE_ATTRIBUTES are still read by the OTLP exporters and package resource from the process environment. Like M1-09/3: forbidigo allows reading the environment only in cmd/bothy. The vendor packages read their variables themselves; in the server both views are the same environment.
M1-10/4 2026-10-11 Middleware is a package function as the brief fixes it; it reads per request the state of the last Setup (trust of the remote parent, server logger) from an atomic pointer. Before any Setup a remote parent is not trusted and the access log goes through slog.Default() behind the allowlist filter. serve (M1-11) calls Setup before it builds the chain. The brief gives Middleware(next) no telemetry argument; a snapshot keeps the request path free of locks.
M1-10/5 2026-10-11 The log allowlist filter resolves LogValuer values and flattens groups (slog.Group, WithGroup) into dotted keys; a flattened key must be attr.Allowed, and only scalar values (string, number, bool, duration, time, []string) pass, so an error or struct value is dropped even under an allowed key. The span exporter keeps only scalar attribute values as well. One key form in JSON and OTLP that matches the catalog and the collector allowlist; a group must not smuggle in a key the catalog does not list, and an encoder would write the text of an error, which signals.md "Logs" keeps out.
M1-10/6 2026-10-11 Span export filter: tracetest.SpanStubFromReadOnlySpan(...).Snapshot() rebuilds every span with only allowed keys on the span, its events and links, clears the status description and, on server spans, removes server.address and server.port. The pinned SDK v1.47.0 has no supported span attribute filter (brief D5). otelhttp fills server.address and server.port of a server span from the Host header of the client, an open and client-controlled set; the status description is free text.
M1-10/7 2026-10-11 A method outside the nine standard HTTP methods is _OTHER in the access log and on the duration metric and HTTP in span names (as the HTTP semantic conventions do). The view of http.server.request.duration keeps exactly http.request.method, http.route, http.response.status_code and error.type with the boundaries of signals.md 4.7, drops the otelhttp body size histograms and turns exemplars off. error.type of a 5xx response is its status code. Metric attributes only from closed sets (17 rule 9, signals.md "Cardinality"): otelhttp would add server.address from the Host header, and exemplars would carry trace ids and the filtered attributes. The body size histograms are not in the catalog.
M1-10/8 2026-10-11 The OpenTelemetry error handler writes the constant message telemetry export error with error.type set to the Go type of the error, at most once per exportErrorInterval (setup.go) and only to stdout. ServerErrorLog maps every line of net/http to http server error with error.type tls_handshake, accept, panic or other. Exporter errors quote endpoints and foreign messages; an OTLP record about a failed export could fail the same way, and a collector that is down would otherwise write a line per export. net/http lines carry client addresses (SEC-LOG-5).
M1-10/9 2026-10-11 NewPseudonymizer(ctx, keys, ref, rotation, clock) and (*Pseudonymizer).PseudonymWithPeriod are exported besides the API of the brief; KeySource is the one-method interface *secrets.Provider satisfies. A principal id must be a UUID in canonical text form (any letter case); the period index rounds toward negative infinity. bothy telemetry pseudonym needs the pseudonym and its period start without Setup, which would start exporters. privacy.md fixes the input as the lowercase UUID; another form would give a pseudonym nobody can look up.
M1-10/10 2026-10-11 bothy telemetry pseudonym <principal_id> [--at TIME] [--config FILE]: output pseudonym <hex> and period_start <RFC 3339 UTC> on stdout; exit 0, 1 when the key cannot be read or is shorter than otel.MinKeyBytes, 2 for a usage error, a principal id that is not a UUID or a configuration that does not load. Flags may follow the id. 02 "Commands of the binary": 2 is a call or configuration error. Unlike config check, whose result is the check itself, a configuration that does not load is a wrong call here.
M1-10/11 2026-10-11 Commands log through otel.NewCommandLogger on stderr: config check --secrets and telemetry pseudonym at telemetry.logs.level, info before the configuration is loaded. secrets init and export do not log. Brief scope cmd/bothy: the same allowlist for every output.
M1-10/12 2026-10-11 Registered configuration keys (M1-07 had not): telemetry.enabled, telemetry.traces.sampler_ratio (0 to 1), telemetry.traces.trust_remote_parent, telemetry.logs.exporter, telemetry.logs.level, telemetry.pseudonym_key_ref (chapter 17), privacy.telemetry_pseudonym_rotation (chapter 20, at least 1s) and instance.environment (chapter 16), defaults from the chapter tables. telemetry.dashboard_url comes with the admin console that links it. The period index divides by whole seconds. TestConfigKeysMatchChapterTables holds the defaults.
M1-10/13 2026-10-11 sloglint (static-msg, attr-only, no-raw-keys, context: scope, verified with golangci-lint config verify) checks product code; tests are exempt. platform/secrets keeps its own constant for error.type, and kernel/errs constants for the keys of its LogValue group. Tests log arbitrary keys on purpose to prove the handlers drop them. platform/otel depends on platform/secrets, and platform cycles are not allowed (architecture/modules.yaml), so secrets cannot import otel/attr.
M1-10/14 2026-10-11 attr.Allowed is false for the resource key bothy.server.role and the metric-only key bothy.signal; both are constants of attr with their scope. genai.ConventionRef names the convention repository and its status as read on 2026-10-10 without a commit. signals.md lists bothy.server.role as a resource attribute and bothy.signal only on bothy.telemetry.dropped. signals.md records no commit of the conventions, and the brief forbids inventing one.
M1-10/15 2026-10-11 The span export filter also clears the trace state of every span, its parent and its links; propagation to outgoing calls keeps it. The trace state comes from the tracestate header of the client, free text outside the header allowlist; a probe of the closing review showed it on exported links (default) and spans (with trust_remote_parent). TestTraceStateNeverExported.
M1-10/16 2026-10-11 After the handler, the access log step writes the route it used back to r.Pattern of the request otelhttp passed on ("" for unmatched). otelhttp takes http.route of the duration metric from r.Pattern, and that value wins over the labeler; below an outer ServeMux with a prefix pattern and http.StripPrefix the metric showed the prefix while span and log showed the route of RecordRoute (TestRecordRouteWinsOverOuterPattern).
M1-10/17 2026-10-11 A handler panic passes Middleware unchanged and is re-raised in the deferred call that caught it; before that the access log and the span record status 500 (or the status already sent) with error.type. The duration metric does not count a panicking request. The recovery of 02 9.1 step 1 sits outside step 2 and answers the request; its stack must still show the panicking frame (TestPanicKeepsAccessLog checks both). otelhttp records the metric only when the handler returns; counting it anyway would mean answering inside step 2, which is the job of step 1.
M1-10/18 2026-10-12 Ported onto the English main: where M1-10 and M1-12 both extend a list (config key groups in internal/wire/config.go, the expected keys of TestConfigSchemaBuilds, the golden output of bothy config print, the help text of bothy, platform entries of the module register), the result holds both; the rules instance., telemetry. and privacy.telemetry_pseudonym_rotation leave tools/configdoc/planned.yaml, because their keys enter the schema (D6); internal/wire/routes.go (M1-17) names the key of the "route check failed" record through the constant logKeyError (value error), so sloglint no-raw-keys passes without changing the record (M1-17/7); comments and tests name the English chapters and headings (the collector test reads signals.md section "3. Resource attributes"); go mod tidy and the generated files are run again on this stack. All repository content is English (D-W-94); the German integration commit recorded the same list unions and the sloglint constant in two rulings of its own, which this note replaces. The help column of bothy is aligned to telemetry, and the config check --secrets test with the server schema expects the skipped migrate.* references of M1-12 and the resolved telemetry.pseudonym_key_ref, in key order. go mod tidy raises the indirect go-openapi/jsonpointer to v1.0.0 (dropping swag/jsonname), golang.org/x/mod to v0.41.0 and golang.org/x/sync to v0.23.0, and the direct otelhttp v0.72.0 replaces the indirect v0.71.0; the OTel exporters bring golang.org/x/crypto v0.57.0, golang.org/x/text v0.42.0 and golang.org/x/net v0.60.0; go generate ./... leaves the REST code unchanged. The constant keeps the key error, which TestServe_AbortsOnUnknownAction reads; switching to error.type would drop the operation and action names that M1-17/7 promises. M1-11, which wires the route step to the server logger of M1-10, decides how the joined error reaches the operator, because the allowlist handler drops the key error by design (signals.md "Logs").

M1-11 Lifecycle, listeners, health and shutdown

No. Date Ruling Reason
M1-11/1 2026-10-12 D4: the runner listens for SIGTERM and SIGINT with a buffer of two. The first signal starts the orderly shutdown, also during the start: the start context ends, the components started so far are stopped through the plan, and the exit code is 0. A second signal at any time ends with 3 without further steps. The end of the context of Run counts like a first signal. Brief D4 and 02 "Shutdown". A signal during a long database wait is an operator stopping the process, not a failed start; 1 would make an orchestrator report a crash.
M1-11/2 2026-10-12 wire.Serve(ctx, *config.Values, Role, Deps): the typed configuration of M1-07 is *config.Values; a type config.Config does not exist. Each package binds its own Config struct (httpserver.Config, wire.ServeConfig, otel.Config, pg.Config). Deps carries the environment, stdout, clock, signals, version, the migration set (tests) and the module handlers. The brief names *config.Config with the note "use the typed configuration type M1-07 created".
M1-11/3 2026-10-12 The secret provider is built in Serve before any component, because otel.Setup resolves telemetry.pseudonym_key_ref through it. The start component secrets then resolves database.password_ref once, before the internal listener opens, so a missing or unreadable password file ends the start at once with exit 1 instead of after startup.dependency_wait. 02 "Start" puts telemetry first and secrets second, and 02 "Failure of a dependency" makes the secret provider fail fast. The provider itself resolves lazily.
M1-11/4 2026-10-12 The line server ready carries bothy.server.role as the brief asks, but the attribute catalog of platform/otel keeps that key on the resource only, so the stdout JSON drops it and OTLP carries it on the resource of every record. The listener lines carry server.address and server.port, which the catalog allows on logs. signals.md "Resource attributes" and attr.Allowed are the contract of M1-10; widening the catalog is outside this ticket. The role is still on every exported record.
M1-11/5 2026-10-12 Stop step budgets: 0 means the step is bounded only by the rest of shutdown.total_timeout (readiness, pool, internal listener). A step that outlives its budget is left running and the plan moves on with the log line shutdown step timed out; a step error is logged as shutdown step failed and does not change the exit code; a panicking step counts as failed. Exit 1 comes only from a total that is spent. Steps of the same phase run in reverse registration order. The budget sum is checked when Run begins, over the steps registered by then; wire.Serve registers all its steps before Run. 02 "Shutdown": exit 1 is reserved for a shutdown over the total. A hanging step must not hold the whole plan; the orchestrator's longer grace period remains the backstop.
M1-11/6 2026-10-12 wire.Serve calls MountRoutes as start step 7 now, because M1-17 is on the base; with no enabled operation it mounts nothing. The public listener serves httpserver.Public(mux): a request no route matches gets 404 with an empty body; a method mismatch also gets 404 until M1-18 brings the problem format. The fallback asks ServeMux.Handler for the pattern instead of registering a catch-all route, so the Semgrep rule bothy-go-route-registration needs no exception on the public side. The internal mux registers /healthz and /readyz with a nosemgrep comment that names R-AZ-22. M1-17/7 promised that M1-11 calls the step before the public listener opens. A catch-all pattern on the public mux would bypass the route check the rule protects.
M1-11/7 2026-10-12 Bounds of the new keys: http.read_header_timeout, http.idle_timeout, shutdown.http_grace and shutdown.telemetry_flush_timeout at least 1ms; shutdown.drain_delay at least 0s; shutdown.total_timeout at least 1s; every shutdown.* key at most 1h; http.max_header_bytes from 4096 to 16777216. A zero would switch the limit off in net/http or leave a step without its own budget. One hour bounds the sum, so it cannot overflow. Below 4 KiB ordinary requests with bearer token and DPoP proof no longer fit; 16 MiB bounds the memory one connection can bind.
M1-11/8 2026-10-12 bothy serve writes its JSON log lines to stdout (the stdout exporter of 17, otel.Deps.Stdout) and the cause of a failed start, for example the hint to run bothy migrate up, as one line bothy serve: <error> on stderr. The log handlers keep no error text. 02 "Commands of the binary" puts logs of commands on stderr so scripts can read stdout; serve prints nothing for scripts, and 17 defines stdout as the log output of the server. The stderr line is the carrier of the error text that INT-W2/7 left to M1-11.
M1-11/9 2026-10-12 bothy healthcheck checks the URL itself before any request (scheme http, no user info, host localhost or a loopback address without zone) and quotes nothing of it in errors; the loopback client of platform/egress checks again at connection time. Exit 1 writes one line: status <code>, timeout, connection failed or refused: <reason>. Redirects are not followed (a 3xx is exit 1). The default --timeout of 5s equals the Compose healthcheck timeout; a test reads both. Brief D5. Error texts of net/http carry addresses; a class is enough for a probe.
M1-11/10 2026-10-12 A listener whose Serve ends with an error other than the orderly close reports it through Runner.Fail: orderly shutdown, exit 1. /healthz has no liveness check yet; the log tail, hub and River client report through Fail in their tickets (02 "Panics"). 02 "Health": /healthz needs no own check because a failed core part ends the process.
M1-11/11 2026-10-12 Ticket branch feat/M1-11-lifecycle-health-shutdown (name of the launcher instead of the brief) on the head of int/m1-wave2; this session does not merge it, the PR lane ports it. The launcher's instructions take precedence over the boundaries of the brief for branch name and merge.
M1-11/12 2026-10-12 Red team (verdict JA, four Minors): the total of the shutdown now counts from the shutdown request instead of the first stop step; the drain step returns at once when the public listener never opened; the telemetry step switches the logger back to stdout before the flush, so the last lines survive a flush that outlives its budget. The English addition in the German command table of 02 stays. The first three are reproduced by TestTotalCountsFromTheShutdownRequest and TestServeSignalDuringStartSkipsTheDrain or follow from the code; the table row of telemetry pseudonym set the precedent of English text in that table while the spec is being translated.
M1-11/13 2026-10-12 Scoped re-review of the fixes (verdict JA, two Minors and a note): a spent shutdown total is now an error of Runner.Err naming the total and the phase, so bothy serve prints a cause on stderr for that exit 1 too, and TestTotalCountsFromTheShutdownRequest checks the log line, the skipped step and that error. The order "switch the logger, then flush" in the telemetry stop step gets no test of its own. A test would need an OTLP endpoint that accepts and never answers; the order is two lines with a comment, and the effect (lines on stdout after a hanging flush) does not change an exit code.
M1-11/14 2026-10-12 Port onto main: TestServeReadyAfterStart waits for the line server ready (child.waitLine) instead of reading stdout once after /readyz passed, and finds the public listener through child.port. This is the test fix M1-25 made on its branch (red team round 1), taken into the ticket whose test it is. The child's stdout reaches the buffer through a pipe and readiness passes before the line is written, so a line can still be in flight when /readyz answers 200; under load the test failed with "no ready line" while it passed alone.
M1-11/15 2026-10-13 lifecycle.StopStep.Wait marks a step whose purpose is to wait for its budget; when the budget is spent the runner waits up to waitGrace, bounded by what the total leaves after the budget, for it to return and logs no timeout. The drain step is such a step and returns when its context ends; with shutdown.drain_delay 0 it returns at once. Review comment on pull request 29: the drain step and its context both ended after the drain delay, so every graceful shutdown with a delay could log shutdown step timed out. TestWaitStepReachingItsBudgetIsNoTimeout failed before the change.

M1-13 Unit of work with phase hooks and in-transaction events

No. Date Ruling Reason
M1-13/1 2026-10-12 The module of a hook, handler or event type is the path segment after the last /internal/ of its Go package (_test suffix dropped); wire and platform yield no module, and kernel may publish events but owns no hook or handler. The unit-of-work tests register through the stand-in module internal/platform/pg/internal/fakemodule, which lies inside the platform-pg component of the architecture gate; the tests of kernel/txn store registrations through the unexported half of SetHook and Subscribe and test the derivation on runtime symbol names. Product packages have exactly one /internal/, so the rule equals "segment after /internal/" of the brief; the last occurrence lets a nested fixture act as a module without a test-only switch of the derivation. A fixture below internal/kernel/txn would belong to no component, and make arch rejects such files.
M1-13/2 2026-10-12 A subscription is allowed only when the publisher is kernel or is listed directly in depends_on of the subscriber; a module subscribing to its own events and a subscriber or publisher missing from the register fail the check. The subscriber imports the publisher's port for the event type, and go-arch-lint allows exactly the direct depends_on edges; an own event needs no bus.
M1-13/3 2026-10-12 CheckTxnDirections takes wire.ModuleDeps (module to depends_on); the golden test reads architecture/modules.yaml with go.yaml.in/yaml/v3. wire has no vendor groups in the register, and tools/archgen is a main package; test files are outside the architecture gate, and no second YAML library is added.
M1-13/4 2026-10-12 wire.Modules is the (still empty) container of module objects, and wire.NewTxnRegistry the one function every role calls; bothy serve (M1-11) wires it. No role builder exists yet; one function for all roles keeps the golden file the list of every role.
M1-13/5 2026-10-12 A retry starts at once, without backoff; the deadlock victim then waits for the winner's locks under database.lock_timeout. 02 names no pause and no key for one; the lock wait is already bounded.
M1-13/6 2026-10-12 The rollback uses the request context without its cancellation, bounded by database.statement_timeout; its own error is dropped. A canceled request must still end its transaction cleanly; the attempt already fails with the better error, and pgx closes a connection whose rollback failed ("A rollback failure leaves the connection in an undefined state", dbTx.Rollback in pgx v5.11.0 tx.go, retrieved 2026-10-13).
M1-13/7 2026-10-12 The error after the last retry has code unavailable without an internal cause and without Retry-After; the HTTP mapping (M1-18) adds http.unavailable_retry_after. 14.1 lists no cause for an exhausted retry, and this ticket returns only kernel/errs kinds.
M1-13/8 2026-10-12 Exported besides the API of the brief: txn.Options, ApplyOptions, Options.Covers, NewUnit, Key.Name, Registry.Hook, KindHook, KindSubscribe, ModuleOfSymbol, ModuleOfPackage; pg.DBTX and Exec, Query, QueryRow on pg.Pool; otel.SubscriberSpan. platform/pg reads options, state and hooks across the package boundary; there is no sqlc code yet, so pg defines the sqlc DBTX shape, and the pool must satisfy it without exposing pgxpool, whose Acquire waits as long as the caller's context allows (pgx v5.11.0 pgxpool/pool.go, retrieved 2026-10-13); pg.Pool bounds it with database.acquire_timeout.
M1-13/9 2026-10-12 Registry.SetTracer may be called after Seal; Publish with an unsealed registry is an internal error. The tracer changes only telemetry, never what runs; delivering before the list is complete would skip subscribers silently.
M1-13/10 2026-10-12 txn.RightsChange names principals only; targets (space, channel) come with the authz hook of M1-29, which resolves the affected principals. The brief fixes the signature RightsChange(affected ...ids.ID); 02 "Flow" item 1 also allows a target, which needs the authz resolution this ticket does not build.
M1-13/11 2026-10-12 txn.Unit is a struct of kernel/txn, not an interface platform/pg implements: txn.NewUnit(reg, opts) returns the unit and the only function that changes its phase; platform/pg keeps the transaction under its own context key and accepts it only together with the unit it created. WithUnit and UnitFrom take and return *txn.Unit. The brief asked for an interface, but the deep scan of make arch (go-arch-lint) rejects passing a platform/pg implementation into kernel (Dependency platform-pg -\-> kernel-ports not allowed), and kernel depends on nothing (02 4.5). The phase setter stays with its creator, so code that reads the unit cannot fake the phase that instance.Counters (M1-15) checks.
M1-13/12 2026-10-12 Findings of the red team (minors): CheckTxnDirections rejects a handler whose path holds more than one /internal/; the Semgrep rule bothy-go-tx-only-in-platform-pg also flags Commit, Rollback, txn.NewUnit and txn.WithUnit outside internal/platform/pg; the package documentation says that a module wrapper could still pass a foreign closure off as its own. A deadlock or lock timeout on a context that has already ended comes back as the raw database error, as the brief asks; M1-18 maps it. The last-segment rule of M1-13/1 would otherwise let internal/wire/internal/sync act as sync in the product; pg.Tx hands out the whole pgx.Tx, and a commit or a unit built outside the unit of work would skip the hooks or fake the phase.

M1-15 Sequence counters and instance record

No. Date Ruling Reason
M1-15/1 2026-10-12 The ticket is built on feat/M1-15-instance-counters from int/m1-wave3 (ca30b63) in its own worktree and is not merged locally; the PR lane ports it. The launcher's task overrides the merge step of the brief ("Definition of done").
M1-15/2 2026-10-12 The phase rule lives in instance/domain over the phase names of txn.Phase.String (rights_begin, sync, audit); app passes txn.CurrentPhase(ctx).String(). Unknown names, n < 1 and a wrong phase are kind internal before any SQL. The domain layer may import only kernel-core; kernel/txn is a kernel port (02 "Layers within a module").
M1-15/3 2026-10-12 State.RecordMigration never lowers schema_version (UPDATE ... WHERE schema_version <= $1), and the instance step of bothy migrate up reads the goose version inside its own unit of work and records only when it equals the newest migration of the binary. Migrator.Up returns before the steps on a newer database, but the steps run outside goose's lock: a newer binary may migrate between goose and the step of an older one. With both checks the older step never overwrites the newer lock (upgrade.md "Rollback").
M1-15/4 2026-10-12 wire.MigrationSteps(pool, fsys) replaces MigrationSteps(); the instance step runs a unit of work on the pgx pool of bothy migrate and ignores the *sql.DB of pg.Step.Run. Its UnitConfig is zero (session deadlines of migrate, lock_timeout = migrate.lock_timeout, no retry) and its registry is NewTxnRegistry(&Modules{}). The brief asks for a unit of work on the migrate connection, and units run on pg.Pool; OpenSQL wraps the same pool, so both use the migrate connections.
M1-15/5 2026-10-12 pgtest.Main(m, pgtest.WithSteps(wire.MigrationSteps)) migrates the templates of a test binary with the steps of bothy migrate up; internal/wire and cmd/bothy use it, so their databases have the instance row. Databases of one binary share the instance_id of their template. pgtest is in platform/pg and may not import wire (architecture gate); without the row every serve test would end at the new start step.
M1-15/6 2026-10-12 TestCountersOnlyUsedByPhaseHooks uses golang.org/x/tools/go/packages, now a direct dependency at v0.50.0. It loads ./... with tests and reports a package that names instance.Counters, calls one of its methods or has any expression of that type. Allowed are internal/instance and its subpackages, exactly the package internal/wire (an external test package counts as its package) and the packages of the hook handlers of wire.NewTxnRegistry(&wire.Modules{}). x/tools v0.49.0 (the indirect version so far) cannot read the export data of Go 1.27 (version 5); v0.50.0 can (observed with go1.27.2 on 2026-10-12: the type check of go/packages v0.49.0 fails on the export data, v0.50.0 reads it). Allowing only the package wire lets the negative fixtures under internal/wire/testdata fail, and the type check finds a value passed on without its name (fixture countersflow).
M1-15/7 2026-10-12 The start line server version below instance.min_server_version carries the new span and log attributes bothy.version.own and bothy.version.required, added to platform/otel/attr, signals.md 5.3 and the collector allowlist. The brief wants both versions in the log line; messages are constant and only catalog keys pass the log handlers, and service.version is a resource key that the stdout log drops.
M1-15/8 2026-10-12 CheckServerVersion: an empty min_server_version is no lock; an invalid minimum or own version fails closed (not ready). The error has kind unavailable. wire.Deps.Version empty means buildinfo.Version(). upgrade.md "Schema check at startup" item 3 allows a schema without the lock; a minimum the server cannot read must not let an unknown schema serve.
M1-15/9 2026-10-12 bothy_app holds SELECT on instance and SELECT, UPDATE (value) on sequence_counters. appPrivileges lists SELECT for both, because has_table_privilege does not show the column right; TestCounters_ValueNeverDecreases holds the column right and the refused INSERT, DELETE, rename and instance writes. Least privilege as the brief and the grant rule of migrations/README.md ask; setting setup_completed_at (M1-37) adds its own grant.
M1-15/10 2026-10-12 The port Versions of 02 4.3 (lock-free reads for the rights cache and catch-up) is not built here; Counters.Current serves the hook phases only. The brief defines Current in Counters and limits it to the hook phases; the readers of Versions (M1-16, M1-29 caches) come later and add it with their use.
M1-15/11 2026-10-12 TestMigration_InstanceTablesMatchSpec compares pg_constraint without the NOT NULL entries (contype = 'n'), which PostgreSQL 18 records as constraints ("Store column NOT NULL specifications in pg_constraint", PostgreSQL 18 release notes, retrieved 2026-10-13); nullability is compared through information_schema.columns. Otherwise the comparison would list every column twice.
M1-15/12 2026-10-12 The test helpers waitPort and waitStatus of internal/wire became waitInternal and waitReadyz. With the new start tests unparam reported their parameters as constant.
M1-15/13 2026-10-12 The static check also reports uses of instance.CounterName and its constants outside the allowed packages (fixture countersshape), and a start below instance.min_server_version logs server started, not ready (warning) instead of server ready. A value converted to any and asserted elsewhere is not covered; the phase guard covers it at run time. Minors 1 and 2 of the code review: an interface of the same shape declared elsewhere must name CounterName; a "server ready" line next to a 503 misleads the operator.
M1-15/14 2026-10-12 03 4.1 sequence_counters now says the migration that creates the table inserts the three rows (00002_instance_counters.sql), not "the first migration". 00001 holds the base privileges; the brief states "the migration that creates the table" (Minor 5 of the code review).
M1-15/15 2026-10-12 The static check also reports expressions whose type contains instance.Counters (a function returning it, a slice of it) and generic instantiations whose type arguments contain it (fixture countersgeneric); wire.InstanceCounters has a unit test. Two binaries at the same schema version may overwrite each other's min_server_version (the guard is schema_version <= $1); that stays, because under rule M1 both constants are valid for the same schema. Minors 1 to 3 of the first red team.
M1-15/16 2026-10-12 Ported onto main, where M1-14 is already in: M1-15 (instance.go) and M1-14 (jobs.go) both declared wire.MigrationSteps, with different signatures. MigrationSteps(pool, fsys) of M1-15 stays as it is (the instance step after goose; pgtest.WithSteps uses it for the test templates); the River step of M1-14 moves into the new MigrateUpSteps(pool, fsys, schema, result), which returns River's step before goose and the steps of MigrationSteps after it. bothy migrate calls MigrateUpSteps; TestMigrateUpSteps holds the order (river before, instance after). The serve tests of M1-15 (instance_test.go) use newServeDB (River's tables, which M1-14 makes a start condition), and TestMigrate_RiverInOwnSchema, TestMigrateUpBootstrap and TestMigrateStatusOutput expect the River line followed by the lines derived from the embedded migration set. This is the integration fix INT-W5/3 and the M1-15 part of INT-W5/5 of the integration branch. Without the fix the module does not compile. The test templates stay without River's tables, because the serve tests of M1-14 add them themselves and TestServeAbortsWithoutRiverTables needs a database without them; M1-15's comment already named this place for River's step.

M1-14 Background jobs with River

No. Date Ruling Reason
M1-14/1 2026-10-12 The module of a job kind is the segment after the last /internal/ of the argument type's package (txn.ModuleOfPackage); the tests use the stand-in module internal/platform/jobs/internal/fakemodule. ValidateArgsType also refuses unexported fields, which the brief does not list. Same derivation as the txn registry (M1-13/1). An unexported field never reaches River's JSON, so the handler would see a zero value.
M1-14/2 2026-10-12 InsertTx is allowed in the phases rights_begin, work and rights_check, and refuses arguments that do not survive River's JSON encoding (a zero ids.ID is no valid UUIDv7) with an internal error; AddPeriodic checks the same. The brief forbids only sync and audit. Found in the first integration run: such arguments failed every attempt at run time; at the insert the caller's unit rolls back instead.
M1-14/3 2026-10-12 A worker whose registry is empty does not start River; Start is a no-op then, as for api. River v0.49.0 refuses to start a client without workers ("at least one Worker must be added to the Workers bundle", client.go, retrieved 2026-10-13), and until M1-16 registers the first handler there is nothing to run.
M1-14/4 2026-10-12 internal/wire registers two stop steps in phase jobs: the soft stop with budget shutdown.jobs_soft_timeout (counted in the budget sum), then jobs_wait with what shutdown.total_timeout leaves after the fixed budgets of the role (soft stop, telemetry flush, and for all drain and HTTP grace), left out when that is 0. Runtime.Stop cancels the running jobs at once when its context ends and waits again on a second call. River escalates to the hard stop after SoftStopTimeout; a single step with exactly that budget would leave no time for the cancelled jobs to record their state before the pool closes, and their rows would wait an hour for River's rescuer. A budget of 0 (the whole rest) let a handler that ignores its cancelled context take the telemetry flush's budget (red team, minor).
M1-14/5 2026-10-12 TestShutdown_SoftTimeoutCancelsAndRetries checks the row as available with attempt 0 and no recorded error after the stop, and the next run on attempt 1. River v0.49.0 marks a job its own shutdown cancelled as interrupted: back to available without counting the attempt (JobSetStateInterrupted with Attempt-1 for a soft-stopped job in internal/jobexecutor/job_executor.go, retrieved 2026-10-13); the brief expected retryable with attempt 1 and an error. The acceptance criterion (cancelled after the soft timeout, runs again) holds.
M1-14/6 2026-10-12 The panic line job panicked carries bothy.job.kind and error.type=panic, not the job ID; the job row records jobs: job <kind> panicked, never the panic value. The attribute catalogue (signals.md 5.3) has no job ID key, and the log handlers drop unknown keys; the line lies inside the job span, and the row with its attempt is the place to find the job. A panic value can carry content.
M1-14/7 2026-10-12 River's nightly reindexer is off (ReindexerSchedule = NeverSchedule); all other River defaults of v0.49.0 stay (README of platform/jobs). bothy_app owns no index of River, so REINDEX could only fail every night; reindexing stays with the owner role.
M1-14/8 2026-10-12 bothy migrate validate stays without a database and checks River's embedded migrations (load, numbering); the database check ValidateMigrations runs at the start of bothy serve for every role, and status prints the River version. 02 "Commands of the binary" says validate needs no database; River's Validate needs one. Checking at the start is the counterpart of the goose version check (R-AR-16).
M1-14/9 2026-10-12 The schema step of migrate up also revokes PUBLIC on jobs.schema, grants on everything that already exists besides the default privileges, and runs under a session advisory lock; jobs.schema must not be public or information_schema or start with pg_; reserved words such as user stay allowed. Idempotent also for tables an earlier run created; two concurrent migrate up must not race on CREATE SCHEMA (pg Step contract); River tables must never land in the domain schema (R-AR-09).
M1-14/10 2026-10-12 API details beyond the brief: New takes *pg.Pool, and pg.Pool gains PGX() for River's driver; jobs.Config gains Logger; a nil tracer means the global provider's; Migrate returns what it applied; lifecycle.Runner.FailOn watches Failed. jobs.queue.*.max_workers are bounded from 1 to River's QueueNumWorkersMax. River's pgx driver needs the pgx pool; the command prints what River applied; the lifecycle stays free of platform/jobs; QueueNumWorkersMax (10_000 in client.go, retrieved 2026-10-13) is River's limit per queue.
M1-14/11 2026-10-12 The work happens on branch feat/M1-14-background-jobs from int/m1-wave3; it is not merged locally into a base branch, the PR lane ports it. Launcher task overrides the brief's local merge.
M1-14/12 2026-10-12 jobs.Migrate and jobs.New for worker and all refuse a pool below two connections (database.max_conns) with an error naming the key. The advisory lock of Migrate holds one connection while River migrates on another, and a working River client holds one for LISTEN; River waits for a connection without a deadline, so a pool of one hung until the caller's deadline (reproduced in TestMigrateNeedsTwoConnections).
M1-14/13 2026-10-12 TestQuerier_AcquireTimeout (M1-13) takes its one held connection through Pool.PGX() instead of Pool.Acquire; a test-only change outside the brief's file list. Under the load of make check the first dial took longer than the test's 50ms database.acquire_timeout and failed with unavailable: the database is not reachable before the test reached what it checks; the gate must be green for this ticket.
M1-14/14 2026-10-12 jobs.Migrate takes its advisory lock by polling pg_try_advisory_lock with goose's cadence (pg.MigrateLockPoll, pg.MigrateLockAttempts, newly exported from the existing constants of platform/pg) instead of pg_advisory_lock. lock_timeout = migrate.lock_timeout holds on every migrate connection and also ends pg_advisory_lock, so a second concurrent migrate up failed after that timeout instead of waiting as it does for goose's lock (red team minor, reproduced in TestMigrateWaitsForAConcurrentRun).
M1-14/15 2026-10-12 The doc comment of pg.Pool.PGX says that it is meant for platform/jobs and that no gate enforces it; no new Semgrep rule. The red team found the old wording promised an enforcement that does not exist; one caller today does not justify a rule outside the brief's scope.
M1-14/16 2026-10-13 The single-method interface jobs.Args keeps its name; SonarQube Cloud's rule for single-method interfaces is suppressed on that line with NOSONAR, as for clock.Clock (M1-05/26). Tests for the error paths of Migrate, MigrationStatus and ValidateMigrations (invalid schema, fresh schema, ended context, lock wait ended by the context) raise the coverage of the new code. Args names what a value is, the arguments of a job, and is the name every job type and the brief use; the -er form is a convention of Effective Go (Interface names, retrieved 2026-10-12). The analysis of pull request 30 found 76 percent coverage on new code against the gate's threshold.

M3-17 Notification decision

No. Date Ruling Reason
D-M3-17-1 2026-10-12 The decision per recipient lives in services/server/internal/notify/domain and is split into ResolveLevel, InQuietHours, Decide, Flush and ReleaseDelayed. They take facts and Now in and never call authorize, read a clock, load a time zone or read configuration: Allowed is the push.deliver result the caller gathered with current rights, each quiet-hour rule carries its loaded *time.Location, and the caller passes the values of notify.coalesce_window, notify.mobile_delay_when_desktop_active and notify.user_default_level and the personal switches resolved with their defaults. TestDomainPurity and make arch hold it. Place: the package, its README. 13 12 point 3 and 02 5 "Layers within a module": the domain holds pure functions; the rules are proven by tables instead of by PostgreSQL, a clock or a provider, and M3-18, M3-19 and M3-23 call one rule set instead of re-implementing it.
D-M3-17-2 2026-10-12 Recipient guards and kinds without thread: the author never gets message, mention or dm for their own message; run_done, run_failed and input_needed reach only the person who triggered the run (which makes "input_needed (own run)" of 13 4.3 hold); security and admin have no thread, so no level, no activity in a thread and no window apply to them (ThreadID must be zero, Window is ignored), quiet hours do. A dm notification requires a DM channel, and message, mention and the run kinds require a channel that is not one; a mismatch is invalid input. For security and admin, Allowed is the recipient rule of 13 2 as the caller evaluates it. Place: 13 2 and 4.4. The domain does not classify events into kinds (M3-19 does), but a wrong caller must fail closed. A message in a DM channel is dm also when it mentions r, because both kinds need the same level and go at once; 05 4.9 defines push.deliver on a thread only.
D-M3-17-3 2026-10-12 muted_until counts only on the chosen own row (thread, channel or space, the first present) and only while it is strictly after now; at now it has passed and the row's stored level applies again. A muted_until on a row that was not chosen has no effect; a row stored as mute stays mute after its muted_until passed. EffectiveLevel carries Level, Source (the step of 13 3.1) and Muted. Place: level.go. 13 3.1 "If muted_until of the selected row lies in the future": "in the future" is strict; an inner row decides even when an outer one is muted, so a thread the person follows keeps its own level.
D-M3-17-4 2026-10-12 Quiet hours: start and end are whole minutes (HH:MM, 0 to 1439), the wall time is compared to the second; start_local = end_local is a window of 24 hours from start_local on each set weekday (screen D9 "all day" is 00:00 to 00:00); the caller loads the zone of the row or users.time_zone; every rule, also a disabled one, must be well formed; a delayed push keeps the Quiet flag of its decision. Place: 13 3.4, quiet.go. The schema stores HH:MM; second precision makes 06:59:59 quiet and 07:00:00 not. 13 3.4 point 3 already covers end_local <= start_local, so equal times are a full day, not an empty window. The gateway copy of the same notification went out quietly at once, and the delay is at most notify.mobile_delay_when_desktop_active. A broken row fails closed instead of being skipped silently.
D-M3-17-5 2026-10-12 Window rules: the window of (recipient, thread) is open while Now < End and closed at Now == End; a kind with thread that sends while no window is open opens one until Now + notify.coalesce_window, whatever its kind; message in an open window is collected; urgent kinds in an open window send and leave the window unchanged; a window length of zero opens none. Flush (kind message only) gives nothing_collected for nothing collected and read for an unread count of zero or less; otherwise allowed, level, activity, quiet and route run again at the flush time, Author and Window are ignored, and a send carries the unread count of 03 read_states and opens the next window; a flush that sends nothing leaves the window closed. Place: 13 4.5, decide.go, flush.go. 13 1 point 3 "One notification per thread and window" must hold under a steady stream, and the first message after a quiet period must still go at once (R-PN-06). Rights, level and quiet hours may change during a window; a bundle has no single author.
D-M3-17-6 2026-10-12 Activity and routes: a device is active in the thread with a connection that is not idle and whose focus holds the thread, on any platform, and that stops the notification on every device; the desktop is active with a macos or windows device connected and not idle. Routes follow the order of Devices: a connection (idle or not) gets gateway; otherwise a device with registrations gets push_delayed (NotBefore = Now + notify.mobile_delay_when_desktop_active) if it is ios or android, the desktop is active and the delay is above zero, else push; a device with neither is left out. No route at all gives none with no_route and opens no window. ReleaseDelayed drops a delayed push exactly when the read position (the greater of last_read_seq and threads.unread_floor_seq) reaches the notification's thread_seq; a notification without a message is always released. Place: 13 4.4 and 4.6, decide.go, delay.go. 13 4.4 and 4.6 name the conditions; an idle desktop window still gets notification.created but does not count as someone watching. A notification that reaches no device must not open a window, or the next message would be collected and never shown.
D-M3-17-7 2026-10-12 Invalid input: ResolveLevel, InQuietHours, Decide and Flush validate first and return errs.New(errs.CodeInternalError, ...) with the zero result (for Decide and Flush outcome none, no routes) for an unknown kind, level or platform; a zero Now; a negative window length or delay; a kind with thread and a zero ThreadID, or security or admin with one; an empty or unknown instance default for a kind with thread; the DM mismatch of D-M3-17-2; a zero or duplicate device ID, a zero registration ID or a zero thread ID in a focus; a malformed quiet-hour rule; for Flush a kind other than message or a negative Collected. The error text names the field, never a value. Outcome is an integer enum whose zero value is none, so the zero Decision never sends. Place: the package. Fail closed: a broken input never yields send or collect. The caller built the facts from its own tables, so a broken fact is a server fault (internal_error), not a client error.
D-M3-17-8 2026-10-12 Branch feat/M3-17-notify-decision from int/m1-wave5 (46d20a0); this session does not merge it locally and does not remove the worktree, the PR lane ports it. Text added to chapter 13 and the notify README is English (the ticket was built on the German chapters and ported onto the English ones). The rows stand in a section per ticket like the M1 rulings, with the IDs the brief names. A test helper file fixtures_test.go holds the shared fixtures of the test files the brief lists. The launcher's instructions take precedence over the brief's merge step and language; merged layout wins over the brief (D-W-100).

M1-18 HTTP middleware chain and problem details

No. Date Ruling Reason
M1-18/1 2026-10-12 The request validator is github.com/pb33f/libopenapi-validator v0.16.1 (MIT, OpenAPI 3.1, JSON Schema 2020-12: its LICENSE and README, retrieved 2026-10-13) with github.com/pb33f/libopenapi v0.41.3. The probe tests TestValidator_Probe_* pass on the first library: additionalProperties: false, unevaluatedProperties: false on an allOf composition, format: uuid (with format assertions on), enums, minLength, maxItems, oneOf with discriminator, header and path parameters; kin-openapi was not needed. The validator keeps only the operations the chain mounts (the model is pruned before the schemas are compiled), and an operation it does not know fails closed with 500. The brief names the library and its fallback. Compiling every request schema of the 29 000-line contract took about 3 s on the build machine, against 5 s of perf.startup_ready_max, for routes that are not served; the unmounted operations answer 404 at the mux anyway.
M1-18/2 2026-10-12 platform/httpserver has its own Operation and Route types; internal/wire converts routes.Operation with HTTPOperation. The harness httpservertest builds the chain around these operations. The validator takes the contract as bytes; api/rest.Spec embeds api/rest/openapi.gen.yaml. The module register gives platform no edge to api (the generated layer), and the harness lives in the platform-httpserver component; a copy of six fields keeps the platform package independent of the generator. A test (TestHTTPOperationCarriesEveryField) holds the conversion and the equal enum values.
M1-18/3 2026-10-12 FieldError code vocabulary of schema validation: required, unknown_member, invalid_type, invalid_format, invalid_enum, too_long, too_short, too_large, too_small, too_many_items, too_few_items, pattern_mismatch, invalid; use cases keep limit_reached and not_enabled of 06. pointer names the member itself also for required and unknown_member (the validator reports those at the enclosing object; the chain appends the member name). detail is a fixed English text per code and never repeats the value. The brief fixes the list; 06 section 5 item 3 already names limit_reached and not_enabled, so the contract description lists both sets.
M1-18/4 2026-10-12 If-Match: * on an operation that requires If-Match is 428 precondition_required, like a missing header. A weak tag, a list or a non-decimal value is not valid for the use case, which answers 412 version_conflict with current. The wildcard would skip the version check the contract demands; a strong comparison of a weak tag fails (RFC 9110 section 13.1.1, retrieved 2026-10-13).
M1-18/5 2026-10-12 Problem titles come from x-problem-codes of the schema Problem (errs.Code.Title, held by TestTitlesMatchOpenAPI), not from the title rule of the brief. The rule (code with spaces, first letter upper case) disagrees with the contract for cursor_invalid ("Invalid cursor"), use_dpop_nonce, invalid_dpop_proof, step_up_required, channel_read_only, title_manual, idempotency_key_reuse, rate_limited and unavailable; R-API-23 makes x-problem-codes the one source of titles.
M1-18/6 2026-10-12 Null repair: the response writer buffers application/json and application/problem+json bodies, removes object members whose value is null and logs response contained JSON null with http.route and error.type = null_member; a null array element, a top-level null, an integer beyond maxSafeInteger (the JSON safe-integer limit, sanitize.go) or a body that is not one JSON value replaces the response by 500 internal_error (response refused). Clean bodies go out byte for byte. The harness fails every handler test that triggers the repair. The JSON Pointer of the removed member is not logged. ADR 0053 and 06 R-API-03. The log allowlist of M1-10 has no key for a JSON Pointer, and adding one needs signals.md and the collector allowlist (M1-10 scope); the route and the harness failure locate the handler.
M1-18/7 2026-10-12 Unmatched requests (404, 405) carry no instance. instance is optional in RFC 9457 and in the schema Problem; without a route the server cannot know which segments are secret ({token} of postIncomingWebhook is not mounted yet, and a later route could add another), and echoing a raw path could put a webhook token into a response (SEC-LOG-3). Matched routes write the path with every {token} segment of the template kept as {token}.
M1-18/8 2026-10-12 A body on an operation without request body is 415 unsupported_media_type; a missing required body is 400 validation_failed with errors[] {pointer: "", code: required}. Multipart and octet-stream bodies are not read by the chain: their limit (file.max_upload_bytes) and transfer deadline apply while the handler streams, and their parameters are validated. SEC-IN-1 rejects what the contract does not describe; reading an upload into memory would defeat the stream limit of SEC-IN-3.
M1-18/9 2026-10-12 The recover step logs panic recovered with error.type = panic, the correlation ID and code.function.name of the function that panicked first; the stack itself is not logged. A panic after the response started becomes http.ErrAbortHandler. 02 says "stack only in the log", but the allowlist of M1-10 and the collector drop exception.stacktrace on purpose; the function name locates the panic without new keys.
M1-18/10 2026-10-12 The proof applies to every success of a route, also to an empty response (implicit 200), to a protocol switch (Hijack is refused without a check) and to a success produced by a stage before StageProof (a slot of a later ticket): without a check of the route's action it becomes 500 internal_error. M1-20 replays a stored response only after recording the check of its action. Fail closed (B7, R-AR-14): no code path may answer a success that no authorize call allowed.
M1-18/11 2026-10-12 server.public_url enters the schema as a required key of kind url, with the cross-check public_url_origin (origin form, as 16 asks of bothy config check); api.cors_allowed_origins gets cors_origins. Every command that loads the server configuration (serve, migrate, config, telemetry) therefore needs server.public_url; Compose sets it for api, worker and migrate (x-server-env), and the tests set https://chat.example.com. 16 "Configuration values of this chapter" says "no default, required"; the DPoP htu of StageAuthenticate is built from it. One schema serves all commands (M1-07).
M1-18/12 2026-10-12 W7 closes every object schema reachable from request bodies of operations under paths with additionalProperties: false; maps (additionalProperties with a schema) and JsonObject stay open; webhooks under webhooks: are not walked. The generator check request schemas closed (api/routes/gen/closed.go) fails make codegen-check on a new open request schema and names it; a schema composed with allOf must carry unevaluatedProperties: false. Schemas shared with responses or events are closed too. The brief's rule; webhooks are requests the server sends, where R-API-04 keeps additivity. A shared schema cannot be both; SEC-IN-1 wins for the request (W7), and clients keep ignoring unknown members in responses by the tolerance rule of 06 (G5).
M1-18/13 2026-10-12 kernel/actor gets the port this ticket needs: Actor (07 section 3 fields without acting_for and view_as), Credentials, Authenticator with Authenticate and Revalidate, WithActor, FromContext and AnonymousOnly. kernel/authorize gets only the check log; the Authorizer interface comes with M1-28 and repeats the RecordCheck contract. M1-05 left both packages as documentation only. acting_for (SR9) and view_as (07 9.2) come with the tickets that verify them.
M1-18/14 2026-10-12 Correlation ID: the chain reuses otel.CorrelationID (M1-10) and stores the value with reqmeta.WithCorrelationID; without a span (a unit test without the middleware) it is 16 random bytes. The brief allows reusing an M1-10 helper with the same purpose; kernel/reqmeta stays standard library only.
M1-18/15 2026-10-12 The task of this run overrides the brief's merge step: the branch feat/M1-18-http-chain is not merged locally; the PR lane ports it. Instruction of the launcher.
M1-18/16 2026-10-12 Port onto the English main: the default cell of server.public_url in chapter 16 "Configuration values of this chapter" reads no default, required, the term rule D5 of tools/configdoc reads as required; the translation had written none, required. Section references to German headings are replaced by the English headings. The request schemas that main gained after the ticket was built (HostReportedSessionUser, HostSessionUserAssign, InvocationTokenRenew) are closed as W7 requires. TestConfigKeysMatchChapterTables of M1-07 reads the same term no default, required, and TestJobsConfigBindsDefaults of M1-14 sets the now required server.public_url. With this ticket the key enters the schema, so D5 compares the cell for the first time and make config-doc-check failed on the old wording.
M1-18/17 2026-10-12 TestCountersOnlyUsedByPhaseHooks (M1-15) no longer lets packages.Load type-check: it loads names, compiled files, imports and export files (NeedExportFile), then type-checks every matched package and test variant one after the other with go/types and a fresh go/importer (gc, reading the export files) per package, with the module's Go version and the gc sizes. What the check reports is unchanged (TestCountersOnlyUsedByPhaseHooks_ReportsFixtures). On the integration head go test -race ./internal/wire/ failed in 2 of 4 runs with a data race inside go/types (Go 1.27.2, observed with the race detector on 2026-10-12): two parallel checks of packages.Load expanded the same imported generic instance (Named.unpack writes fromRHS, Checker.isComplete reads it without the lock). M1-18 adds the packages that make the collision likely; M1-15 alone passed 9 of 9 runs. The race fails every test that runs at the same time and make test runs with -race, so CI would fail at random. After the change 10 of 10 runs passed. Raising the Go pin is not the integration's decision.
M1-18/18 2026-10-13 sonar.exclusions also leaves out **/*.gen.yaml; the routing context field of routeWriter keeps its context.Context with NOSONAR, and two repeated literals become constants. api/rest/openapi.gen.yaml is the byte copy of docs/api/openapi.yaml that the route generator writes for go:embed; the analysis of pull request 35 read its field names (token, credential) as hard-coded secrets, and the source document lies outside the analysed paths. The writer lives for one request, so its context never outlives it.

M1-23 Server image

No Date Ruling Reason
M1-23/1 2026-10-12 The final stage of bothy-server is gcr.io/distroless/static-debian13:nonroot, pinned by the digest of its image index, not scratch. Debian 13 is the newest line the distroless project lists as supported at build time ("Distroless images are based on Debian 13 (trixie)", distroless README, retrieved 2026-10-13). Distroless static brings maintained CA certificates, time zone data and an /etc/passwd entry for nonroot, which a static Go binary needs for outgoing TLS and local time, without a shell or package manager ("distroless images by default do not contain a shell", same README). Its digest is a normal Docker dependency that Renovate (config:best-practices) keeps current; with scratch both files would have to be copied and updated by hand.
M1-23/2 2026-10-12 Branch feat/M1-23-server-image from the head of int/m1-wave3 (ca30b63); this session does not merge it locally and does not remove the worktree, the PR lane ports it. The launcher's instructions take precedence over the boundaries of the brief for base branch and merge.
M1-23/3 2026-10-12 The build argument VERSION in release.yml comes from needs.resolve.outputs.version, like the other steps of the job build on this base. On a base whose job build resolves the tag again (step info, D-W-52 on main), the port uses steps.info.outputs.version, as the brief asks. The base int/m1-wave3 has neither the step info nor the rule release-untrusted-output; a reference to a missing step would be empty and fail the build on the version check. The Dockerfile refuses a version that differs from services/server/VERSION either way.
M1-23/4 2026-10-12 TestReadOnlyRunAsNonRoot passes BOTHY_DATABASE__HOST=postgres instead of BOTHY_SERVER__PUBLIC_URL. bothy config check needs no variable on this base, and server.public_url is not yet a key of the schema: the loader rejects the variable as unknown. One known key still proves that the environment reaches the loader in the hardened container.
M1-23/5 2026-10-12 The image contains the empty directory /var/lib/bothy/signing-keys (default of secrets.signing_key_dir) with owner 65532:65532 and mode 0700, created by COPY --from=build --chown --chmod, not by RUN in the final stage; TestSigningKeyDir checks it. 18 "Server images" rule 3 requires it, and a named volume takes owner and mode from the image on first mount (an empty volume mounted over existing files gets them "propagated (copied) into the volume", Docker volumes, Mounting a volume over existing data, retrieved 2026-10-13), so the server can write its keys without an init step. A RUN in the final stage would need a shell and emulation.
M1-23/6 2026-10-12 The HEALTHCHECK of the image carries the timings of x-server-healthcheck too (interval 15s, timeout 5s, start period 30s, 4 retries); TestHealthcheckMatchesCompose compares command and timings with infra/compose/compose.yaml. A runtime without Compose then probes the same way, and the acceptance criterion "the health check of the image equals the Compose health check" is held by a test instead of by review.
M1-23/7 2026-10-12 The pin check treats a FROM whose reference is built from a variable as a finding unless the digest is written out; stage names compare case-insensitively; only the leading parser directive block counts for # syntax=; a comment inside a continued instruction is skipped. Two repository tests hold the golang tag of the builder equal to the toolchain of services/server/go.mod and the base images in the server README equal to the Dockerfile. A variable cannot be verified statically. BuildKit reads directives only before the first comment, blank line or instruction, so a later # syntax= is a plain comment ("Once a comment, empty line or builder instruction has been processed, BuildKit no longer looks for parser directives", Dockerfile reference, Parser directives, retrieved 2026-10-13). Renovate could bump the golang image without the toolchain, and the README table is a number in documentation that a test must hold.
M1-23/8 2026-10-12 The image tests read BOTHY_TEST_IMAGE and BOTHY_TEST_IMAGE_OCI through one helper with a //nolint:forbidigo and run Docker through one helper with a //nolint:gosec (G204), each with its reason; .golangci.yml is unchanged. Containers start with --pull=never. The suppressions stay at the two places that need them, without widening a path rule. --pull=never keeps the tests offline and makes a missing local image fail instead of pulling a namesake from a registry.
M1-23/9 2026-10-12 The row docker/setup-qemu-action leaves docs/quality/toolchain.md, because release.yml no longer uses the action. Text added by this ticket to German documents (18-release.md, gates.md, the server README) is English. A pinned action without a user is documentation drift. The launcher requires English for everything added; M1-11/12 set the precedent of English text in German chapters during the translation.
M1-23/10 2026-10-12 Red team (verdict NEIN, one Major, three Minors), all fixed: the cleanup of the image tests removes containers under a context without the cancellation of t.Context(), which Go cancels before cleanup functions run, and logs a failed removal; TestCleanupRemovesContainers proves that no container outlives its test. The pin check also checks the images of COPY --from= and RUN --mount=...,from= (a stage name or index passes) and finds Dockerfiles by name in any case (Dockerfile, Containerfile, with a prefix or suffix after a dot). TestImageConfig asserts that the image exposes no port. The Major was reproduced: every make image-test left three containers in state created. SEC-SC-4 covers every image that enters a build, not only FROM, and a lower-case build.dockerfile would have escaped the gate.
M1-23/11 2026-10-12 TestQuerier_AcquireTimeout (M1-13, internal/platform/pg/unit_test.go) retries its first Acquire for up to ten seconds before it holds the only connection; the three checks on the exhausted pool are unchanged. This test file is outside the scope of the brief. The full make check of this ticket failed twice in three runs on this test with "the database is not reachable": the first connection is dialed under the 50 ms acquire timeout, and under the load of make check the dial takes longer. With -cpu 1 it failed one run in four before the change and passed six of six after it. The pool keeps a connection whose acquire gave up, so the retry gets it. Leaving it would keep make check red for every ticket on this base.
M1-23/12 2026-10-12 Port onto main: the build argument VERSION in release.yml comes from steps.info.outputs.version, as M1-23/3 foresaw for a base with the step info. TestReadOnlyRunAsNonRoot passes BOTHY_SERVER__PUBLIC_URL=https://bothy.example.com instead of BOTHY_DATABASE__HOST=postgres, which supersedes M1-23/4. TestQuerier_AcquireTimeout keeps the form of M1-14 (M1-14/13: the held connection through Pool.PGX() without the acquire timeout) instead of the retry loop of M1-23/11. Section references name the English headings of main. M1-18 on main makes server.public_url a required key for every command that loads the configuration (M1-18/11), so the image config check failed with server.public_url: is required; the required key still proves that the environment reaches the loader in the hardened container. M1-14 and M1-23 fixed the same flake; one fix for one cause, and the M1-14 form does not depend on a retry window.

M1-26 Principal tables and directory

No Date Ruling Reason
M1-26/1 2026-10-12 Ticket branch feat/M1-26-identity-principals on int/m1-wave3 (ca30b63); this session does not merge it and keeps its worktree, the PR lane ports it. The launcher's instructions take precedence over the merge step of the brief.
M1-26/2 2026-10-12 kernel/actor had no kind type; actor.Kind gets user, agent, app, anonymous and host, with Valid and IsPrincipal (the three kinds with a row in principals). 07 section 3 lists host among the kinds of the principal context; adding it now spares M2-39 a change of a kernel type.
M1-26/3 2026-10-12 domain.NewOwner checks the reason with kernel/textcheck besides "not blank". Its length limit (audit.reason_max_chars, SEC-AUD-5) stays with the use cases (M1-31), because the key is not in the schema yet. SEC-IN-4 names reasons of administrative actions as display texts.
M1-26/4 2026-10-12 "Blank" for display names and reasons means: only Unicode white space or format characters (category Cf, for example U+200B, U+200D, U+FEFF; Unicode General Category values, retrieved 2026-10-13). A name of only invisible characters makes a person as unidentifiable as a blank one, which is the reason 03 gives for the rule.
M1-26/5 2026-10-12 bothy_app gets SELECT, INSERT, UPDATE on principals, users and owners, no DELETE. 03 "Principles" rule 7: principals are never hard deleted, an owner is revoked by revoked_at, a users row lives with its principal; least privilege by default (migrations/README.md), a later ticket grants what it needs.
M1-26/6 2026-10-12 Field error codes: required, too_long, pattern and the textcheck rules (invalid_utf8, control_character, bidi_control) in the domain; taken for a unique conflict and invalid for a check, trigger, foreign key or not null violation in the adapter. A conflict on the active owner index names /user_id; a primary key conflict (a reused id, only a programming error) and an unknown constraint stay internal. 06 names required and too_long as examples and leaves the other codes open; a field on every mapped error lets M1-18 render errors[].
M1-26/7 2026-10-12 The module register allows the vendor group pgx for identity adapters. The sqlc output (sql_package: pgx/v5) and the repository import pgx and pgtype; 02 section 5 rule 4 admits third-party libraries per adapter through the register.
M1-26/8 2026-10-12 The license gate exempts files under */sqlcgen/* from the SPDX header, like *.gen.go; the generated-code marker rule is not extended to that directory. sqlc writes its own header without a license line and has no option for one (the sqlc configuration reference, retrieved 2026-10-13, has no header setting). The archgen fixture tools/archgen/testdata/layout/internal/chat/adapters/postgres/sqlcgen/db.go is a hand-written stand-in without the marker; Sonar already excludes **/sqlcgen/**.
M1-26/9 2026-10-12 Repository writes use pg.Tx and fail with kind internal outside a unit of work; reads use pg.Querier. wire.IdentityConfig binds app.Config; nothing calls it yet, M1-28 wires the directory and the source. A write outside a unit would skip its phases (02 section 8.1); wiring is out of scope of M1-26.
M1-26/10 2026-10-12 The trigger functions pin search_path to pg_catalog, pg_temp, name public.principals explicitly, and EXECUTE on them is revoked from PUBLIC. Both raise SQLSTATE 23514 (check_violation, PostgreSQL error codes, retrieved 2026-10-13) with COLUMN set, which the adapter maps to the field. A trigger function runs with the rights of the writer ("SECURITY INVOKER ... That is the default", CREATE FUNCTION, retrieved 2026-10-13); a pinned search path keeps a temporary object from shadowing the table, and nobody calls the functions directly.
M1-26/11 2026-10-12 The module README keeps the sections that make register-check reads (tables, actions, allowed ports); only the status line and the code description change. The register check finds those sections by their titles; renaming them is a change of M1-06's tool.
M1-26/12 2026-10-12 Errors of reads that are no database constraint (for example a canceled context) become internal without SQL text; a kernel/errs error from platform/pg (the pool's unavailable) passes unchanged. The port promises no SQL details; the HTTP mapping of cancellations is M1-18's.
M1-26/13 2026-10-12 The bothy migrate tests of cmd/bothy derive their expected output and the versions of their own extra migrations from the embedded set (embeddedMigrations) instead of assuming exactly one migration. The first schema migration broke four of them (expected output of version 1, extra files named 00002_*); deriving from the set fixes the class, so the next migration needs no test edit.
M1-26/14 2026-10-12 Port onto main, after M1-15 and M1-18: duplicate declarations of two tickets in one package are resolved without changing behaviour: kernel/actor keeps Kind and its constants from M1-18 (actor.go) and kind.go of M1-26 keeps only Valid and IsPrincipal; the values are the same strings (TestKindValues). The test helpers column, columns and constraints of migrations/identity_principals_test.go (M1-26) become principalColumn, principalColumns and principalConstraints, because instance_test.go (M1-15) declares the same names with other shapes in the package migrations_test. In cmd/bothy/migrate_test.go both tickets derived the expected output from the embedded migration set; the test helpers for the migration set are main's (M1-26/16). Both tickets were built on the same base and passed alone; together the module did not compile (Kind redeclared, column redeclared). Keeping the declaration of the earlier ticket and renaming test-only helpers of the later one is the smallest change; no exported behaviour changes.
M1-26/15 2026-10-12 Port onto main: M1-15 (00002_instance_counters.sql) and M1-26 (00002_identity_principals.sql) both added migration version 2. The migration of M1-26, the later ticket, becomes 00003_identity_principals.sql with unchanged SQL (its header comment names the English chapter file, so the checksum is recorded anew); migrations.sha256, the identity README and the server CHANGELOG name the new file. sqlc generate regenerates the models of both modules, because both entries of sqlc.yaml read the whole migrations/ directory. goose refuses a set with a duplicate version: TestEmbeddedMigrationsValidate failed with duplicate version 2 and the test templates could not migrate. Neither migration is released, so renumbering the later one is allowed (the lock only forbids changing a merged file's content); the ticket order of the wave decides which one moves.
M1-26/16 2026-10-12 Port onto main: the bothy migrate tests of cmd/bothy keep the helpers main already has from M1-14 and M1-15 (embeddedMigrations, appliedLines, riverVersion), which derive the expected output from the embedded set as M1-26/13 asks; the M1-26 helpers are not added. Section references name the English headings of main. One helper set for one purpose; the derived expectations already cover the new migration.

M1-21 Transport protection

No Date Ruling Reason
M1-21/1 2026-10-12 A secret_ref key with an empty default (chapter cell "empty": server.tls_cert_ref, server.tls_key_ref) is optional: the empty text, as default or from a source, is the value "no reference" and switches its feature off; bothy config check --secrets prints not set for it and reads nothing. A secret_ref key with a reference as default still refuses the empty text. This replaces the rule "no empty secret_ref default" of M1-07/11, as that ruling foresaw. 16 "5.2 Secret provider (ADR 0011)" names "empty" for secrets of a feature an operator can switch off; a separate flag per feature would be a second switch for the same fact.
M1-21/2 2026-10-12 The cross-checks tls_pair (both TLS references or neither), tls_https (TLS needs an https server.public_url) and hsts_whole_seconds (http.hsts_max_age a multiple of 1s; Min 0s refuses negative values) live in internal/wire with the keys, so their tests are in internal/wire and not in internal/platform/config, which has no key of its own. httpserver.ChainConfig checks http.hsts_max_age once more for callers that bypass the loader. The keys and the cross-checks of the server are registered in internal/wire; a kind rule "whole seconds" for one key would add a field to config.Key for a single user.
M1-21/3 2026-10-12 httpserver may use platform/secrets (module register): TransportConfig holds secrets.Ref, and the provider comes in through the narrow interface httpserver.SecretGetter (Get), which *secrets.Provider satisfies. platform/secrets stays unchanged. The brief fixes the reference type of platform/secrets; that package has no provider interface, and adding one there is outside the scope of this ticket.
M1-21/4 2026-10-12 The TLS material is checked at start step 2 by httpserver.CheckTransport and loaded again by ListenPublic at step 8 (the provider cache makes the second read free within secrets.cache_ttl). Only api and all check it; the worker opens no public listener and never reads the TLS references. Bad material must end the start before the database wait (02 "15.1 Start"); a worker container need not mount the certificate.
M1-21/5 2026-10-12 A rotated pair goes through the same checks as at start (PEM chain, matching key, validity, host of server.public_url). A failed pair of versions keeps the previous certificate and logs TLS certificate rotation failed, keeping the previous certificate (error.type tls_rotation) once; it is parsed again at most once per rotationRetry (tlscert.go), so a pair refused for a reason that passes with time (a leaf not yet valid for the server clock) is picked up without a restart. A good pair clears the memory of failed pairs. GetCertificate holds its lock over read and swap, so an older pair cannot replace a newer one. A rotation that replaces the two files at different times can log one such line until both are read again. TransportConfig.Clock (nil: system clock) drives validity and retry. 02 "14.4": a failed rotation keeps the old value; one line per version, not per handshake, and at most one parse per minute for a broken file.
M1-21/6 2026-10-12 The refusal log has constant messages and names the listener in bothy.reason (value public, from the fixed list of listener names); the first refusal per listener has the hint to add the proxy to server.trusted_proxies (Warn), later ones only plaintext connection from an untrusted peer refused (Debug). The log allowlist of platform/otel keeps only catalog keys and the catalog has no listener key; sloglint requires constant messages (its settings in services/server/.golangci.yml, after docs/observability/signals.md "Logs").
M1-21/7 2026-10-12 SEC-HDR-1 (with HSTS) is set in the outermost step before the next handler runs, and a secure writer applies it once more right before the header block goes out, so no handler can drop nosniff, weaken HSTS or send Server. The internal listener uses the same writer through InternalHandler, which also sets Cache-Control: no-store and Cross-Origin-Resource-Policy: same-origin (health answers JSON) and answers unknown paths and methods with problem bodies; health registers GET patterns, which also match HEAD ("A pattern with the method GET matches both GET and HEAD requests", net/http ServeMux, retrieved 2026-10-13). Design note 7 asks that inner responses inherit the headers; applying them at write time closes the gap of a handler that changes them.
M1-21/8 2026-10-12 The TLS listener is an own wrapper around the TCP listener with tls.Server per connection and NextProtos h2, http/1.1, the list net/http uses for ServeTLS (adjustNextProtos in net/http server.go of Go 1.27.2, retrieved 2026-10-13); http.Server.Serve then serves HTTP/2 on connections that negotiated it. The brief has ListenPublic return the listener; the wrapper keeps the TLS configuration inspectable for SEC-T17 (no own cipher list).
M1-21/9 2026-10-12 The SEC-T03 row "503 while shutting down" on the public listener is the 503 unavailable problem of the shared writer: the public listener has no own shutdown answer (readiness turns false, the drain waits, then Shutdown closes the listener). The shutdown state on the internal listener is /readyz 503 with shutting_down, covered by the row "readyz starting" (same writer) and by TestServeReadyzDuringDrain. Nothing in M1 answers 503 because of the shutdown on the public listener; a test row for a path that does not exist would prove nothing.
M1-21/10 2026-10-12 Not covered by SEC-HDR-1: responses that net/http writes itself outside any handler (400 for a malformed request line, 431 for too large headers, the plain answer to plaintext on the TLS port; all in conn.serve of net/http server.go of Go 1.27.2, retrieved 2026-10-13). The 429 row of SEC-T03 waits for M1-19 (internal/platform/ratelimit is not on the base). Those answers are written by the standard library before the handler chain exists; they carry no body of the API.
M1-21/11 2026-10-12 Work happens on branch feat/M1-21-transport-protection from int/m1-wave4 (76b79a5); the branch is not merged into the base locally and the worktree stays, because the launcher's PR lane ports the result. This replaces the merge step of the brief's definition of done. Instruction of the launcher, which takes precedence over the brief.
M1-21/12 2026-10-12 Port onto main: references name the English chapter files and headings of main (the empty default cell reads "empty", as in the chapter tables and rule D5 of tools/configdoc); the planned rules http. and server. leave tools/configdoc/planned.yaml, because every key of both groups is now in the schema; chapter19 already lives in wire/configchapters.go on main; TestServeTLSPublicListener and TestServeWorkerIgnoresTLS start on a database with River's tables (newServeDB), because M1-14 on main makes the jobs step of bothy serve require them. Translation and conflict resolution only; the behaviour equals the approved ticket.

M1-24 Audit chain, recorder and aggregation

No Date Ruling Reason
M1-24/1 2026-10-12 Canonical form of an IP address for ip_hmac: the client IP of kernel/reqmeta after netip.Addr.Unmap() and without zone, in the text form of netip.Addr.String() (IPv4 dotted decimal, IPv6 compressed lower case per RFC 5952, retrieved 2026-10-13, as netip.Addr.String documents, retrieved 2026-10-13); ip_hmac = hex(HMAC-SHA-256(key, text)) in full length (the hex form of the whole HMAC). Every entry of a request that carries a client IP gets ip_hmac and ip_key_id, also for an authenticated actor. One text per address, so ::ffff:192.0.2.44 and 192.0.2.44 and an address with zone give one pseudonym (TestIPHMAC_MappedEqualsIPv4, TestIPHMAC_ZoneIgnored); the full length keeps the owner's check of a known address unambiguous within the retention (20 "4.5").
M1-24/2 2026-10-12 The closed reason list of 07 "4.1" lives in internal/kernel/auditing (Reason, one constant per row in table order, KnownReasons, Reason.Known); authz reuses it with M1-28. TestReasonsMatchChapter07 parses the table and compares both ways and the order. R-DM-28: one list, held closed by a test; the kernel port is the only package both audit and authz may import.
M1-24/3 2026-10-12 Base int/m1-wave4 at 76b79a5, branch feat/M1-24-audit-chain in an own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in internal/audit/README.md (the brief asks for German); its German headings stay, because the module README layout is shared by all modules. Instructions of the launcher, which override the brief's merge step and language.
M1-24/4 2026-10-12 kernel/txn gets Detach(ctx): a copy of ctx outside the unit of work with every other value. RecordDetached, the aggregator and the keyring run UnitOfWork.Do on it and so begin an own transaction even when the caller is inside one. The per-unit state the hook needs (Key, Load, Store) already existed. The brief requires an own unit that never joins an ambient one; platform/pg joins whenever txn.InUnit(ctx) holds, and hiding the unit is the smallest change that keeps actor, correlation ID and client IP.
M1-24/5 2026-10-12 Attrs refuses ip_hmac, ip_key_id and aggregate (the recorder sets them); reason takes only auditing.ReasonText and violations only auditing.Violations, and these two values are refused under any other key. ReasonText is checked for emptiness and with kernel/textcheck; its length bound audit.reason_max_chars stays with the request validation of the action, which answers 400 validation_failed with the field code too_long (SEC-AUD-5), so the key is not in the schema yet. Codes and attribute keys are at most 64 bytes (domain.MaxCodeLen). The brief's comment on Attrs names violations as reserved while its test list accepts violations; typed values under their reserved key satisfy both. A recorder that truncated or refused a long reason after the change would lose the entry or fail a valid action.
M1-24/6 2026-10-12 The flush of the aggregation is the stop step audit_denials in lifecycle.PhaseDatabase, registered by the start step audit after register added the step pool; steps of one phase run in reverse registration order, so it runs before the pool closes in every role. TestAudit_StartFlushesBeforeThePoolCloses holds the order. No new lifecycle phase. lifecycle has no audit phase and is outside the scope; the database phase is the last one that still has the pool.
M1-24/7 2026-10-12 The module reports through the port app.Reporter, implemented by adapters/logs with constant messages and the keys error.type, bothy.action, bothy.reason and db.response.status_code (audit adapters may use pgx for the SQLSTATE). The count of dropped denials and the number of a missing row are not logged, and the principal of a dropped bucket is not logged either. The app layer may not import platform/otel/attr; the attribute catalogue has no key for a count or a sequence number (adding one needs signals.md and the collector allowlist), and SEC-LOG-4 allows people in logs only as pseudonyms, which platform/otel derives and the app layer cannot reach.
M1-24/8 2026-10-12 Record computes the IP pseudonym when it is called (work phase), not in the hook; the keyring reads and creates key rows only in its own transactions. A denial whose key is unavailable is aggregated without IP fields and reported; Record and RecordDetached fail instead. A correlation ID that is not a UUID is left out of the row. A key row created in the caller's transaction would stay cached after a rollback while missing from the table; in the hook the second connection would be taken while holding audit_seq. RecordDenial must never return a storage error; losing the pseudonym is better than losing the denial.
M1-24/9 2026-10-12 The queries of the brief get one more, Rows (rows from_seq .. to_seq in chain order, details decoded with UseNumber), and the hook inserts with one INSERT ... SELECT over parallel unnest arrays. The chain tests recompute every hash from the row read back; M1-50 needs the same read. sqlc does not resolve the multi-argument unnest in FROM (observed with the pinned sqlc on 2026-10-12); set-returning functions in one select list run in lock step (PostgreSQL, set-returning functions, retrieved 2026-10-13).
M1-24/10 2026-10-12 After a failed write of aggregated denials the timer waits one audit.deny_aggregate_window before the next attempt; Record in the audit phase itself is an error. The rules of privacy.audit_ip_key_period, privacy.audit_ip_key_retention and privacy.audit_ip_kek_ref leave tools/configdoc/planned.yaml, because the keys entered the schema (rule D6). A failed bucket is due at once again; retrying without a pause would spin while the database is down. An entry recorded after the hook ran would never be written.
M1-24/11 2026-10-12 Red team round 1 (verdict JA, eight Minors). Fixed: the sqlc entry of audit writes *.gen.go files (M1-05/7); concurrent Record calls in one unit of work append under one lock, so no entry is lost (TestRecord_ConcurrentInOneUnitKeepsEveryEntry); a string attribute that parses as an IP address is refused although it matches the code pattern (K-DP-2); the final flush at shutdown (Aggregator.Close) reports every bucket it could not write with DenialsDropped. Not changed: a batch whose COMMIT succeeded but whose acknowledgement was lost is retried with the same row ids and fails on audit_events_id_key until the cap drops it (new ids would count the denials twice; the case needs a lost acknowledgement); the pool pressure of the keyring at a period change and the synchronous write of an evicted bucket are named in the README limits and follow from 20 "4.5" and SEC-AUD-6; the count in the drop log is M1-24/7. The identity entry of sqlc.yaml keeps its file names, because it is outside this ticket. The owner rule lists Minors once; the four fixes were small and each has a test.
M1-24/12 2026-10-12 Port onto main: references name the English chapter files and headings of main; the subsection "Tables" in the code description of the audit README is "Tables and grants", because the English README already has the section "Tables" that make register-check reads and a second one is a finding; the three privacy.audit_ip_* rules leave tools/configdoc/planned.yaml; non-ASCII test strings stay, they test UTF-8 handling, written without umlauts (for example Révision) so the language gate passes; the golden chain testdata/chain_v1.json keeps its independently computed vector and stands on the allow list of the language gate. The reason list gains terminal_no_session_user (SR12), which chapter 07 section 4.1 on main lists after i6_violation; TestReasonsMatchChapter07 holds the order. Translation and conflict resolution only; the behaviour equals the approved ticket.

M1-25 Action catalogue in code

No Date Ruling Reason
M1-25/1 2026-10-12 Branch feat/M1-25-authz-catalogue in its own worktree from the head of int/m1-wave4 (76b79a5); this session does not merge it locally and does not remove the worktree, the PR lane ports it. The launcher's instructions take precedence over the brief's merge step.
M1-25/2 2026-10-12 The generator reads the schema M1-02 chose (tools/catalog/README.md). Fields M1-02 stores as machine values keep them (direction of ops and channel ops, effect); fields it stores as 05 text stay raw (right, audit_text, receive_text, action_text). Audit holds the 05 cell (yes, denials, no or the conditional text, as action_catalog.py renders it); ReceiveAction and ActionsText hold the 05 cell (the *_text field, else self or the backticked action). MentionedActions are the catalogue ids among the words of that cell plus the structured fields (receive_action, action, also_actions). The normalization table of the brief applies on top: yes/no, conditional/fresh, none and comma lists with notes are accepted as alternatives. The brief asks for the field names M1-02 chose and writes its normalization rules for fields stored as 05 text; most are structured in actions.yaml, so the 05 text is derived the same way chapter 05 renders it.
M1-25/3 2026-10-12 owner: as_target (the one value outside yes/no, used by access.preview) gives Owner = false and OwnerAsTarget = true. The domain types carry a few fields beyond the brief's sketch: ActionSpec.OwnerAsTarget, DeliveryCheck, AppScope; EventSpec.Resources (normalized kinds next to the raw Resource, because one event type names two kinds) and Receivers; OpSpec.PerEventReceiveAction; ChannelOpSpec.ActionText. Resource kinds match ^[a-z][a-z0-9_]*$; ResourceKind constants cover the kinds of actions and events. The catalogue holds this information; dropping it would force M1-28 to read actions.yaml again. No catalogue content is invented.
M1-25/4 2026-10-12 bothy.authz.errors for unknown_action: app.Catalog depends on the port app.ErrorCounter; adapters/telemetry.ErrorCounter implements it with OpenTelemetry (meter scope of the authz module, same name, unit and description as the instrument of platform/httpserver), and the register allows the authz adapters the vendor group otel. NewCatalog(log, opts ...Option) takes the counter through WithErrorCounter; Spec(a) keeps the brief's signature and logs with context.Background(). 02 "Layers within a module" forbids libraries in the application layer and wire may not use otel; 02 section 7.1 shows adapter_vendors: [pgx, otel] as the way to allow it.
M1-25/5 2026-10-12 The start check logs one ERROR line registration check failed per violation with error.type (a closed set of violation kinds, app.Violation*) and, where known, bothy.action, bothy.ratelimit.class and bothy.event.type. Operation id, op name and module are not log attributes; they are in the returned *app.RegistrationError, one line per violation, which bothy serve writes to stderr. The allowlist handler of platform/otel drops every key outside signals.md (INT-W2/7); adding keys to the catalogue of signals is outside this ticket.
M1-25/6 2026-10-12 The check runs for every role (a worker emits events too), before NewChain and MountRoutes; MountRoutes gets the same authz catalogue through Catalog.Class, which rejects unknown actions, system actions and actions without a class. routes.SpecCatalog() stays for the tests of api/routes; api/routes declares no action constants, so scope item 7 needs no regeneration. Deps.Registrations lets a test replace the module Register functions; nil means the production list moduleRegistrations (empty in M1). VerifyRegistrations also reports a registration without a module name. One definition of "known" in production; the empty list documents where module tickets add their function.
M1-25/7 2026-10-12 .golangci.yml excludes gosec G101 (hard-coded credentials, gosec rules, retrieved 2026-10-13) for internal/kernel/authorize/catalog_ids_gen.go (ids such as auth.token.refresh look like credentials). The coverage filter of make coverage matches .gen.go only, so the two _gen.go files count; the tests exercise every function in them. Generated code cannot satisfy both linters; changing the Makefile coverage filter is outside the scope, and the files are covered.
M1-25/8 2026-10-12 Text this ticket adds to German documents (02 section 4.5, services/server/api/README.md, the authz README) is English. The launcher requires English for everything added; same precedent as M1-23/9 and M1-11/12. CONTRIBUTING asks for German specification text; the translation of the chapters is pending.
M1-25/9 2026-10-12 Review findings fixed: catalogen reads exactly one YAML document, rejects null scalars and an empty resource list, type-checks the fields it does not store, writes both files through temporary files and renames them only when both are written, and refuses an identifier that equals a name declared by hand in the target package (read from the other Go files of the output directory) or one of the generated type and function names. The Semgrep rule also catches string literals passed to Spec, Registry.Event, Registry.Op, Registry.ChannelOp and a dot import; TestNoConversionInsideThePackage covers unqualified conversions inside kernel/authorize. The rule excludes only *_gen.go, *.gen.go and *_test.go. Strict decoding and "fails with entry and field" also apply to these inputs; a generated file that does not compile is a worse message than the generator's.
M1-25/10 2026-10-12 Red team round 1 (NEIN, one Major): a resource text with a note followed by more text (terminal_session (a) or channel (b)) passed and dropped kinds; every part must now be a kind with at most one note, with a fixture. Minors fixed in the same change: explicit YAML tags, aliases, null and booleans in name lists and quoted "true" are rejected, YAML booleans count in any case; a failed second rename restores the first file; EventSpec.Note keeps the column "Note"/"Additional condition"; the check runs before the HTTP chain is built; an unknown route action is logged with bothy.action; route messages name the pattern once; a route binding needs operation id, method and pattern; a zero or nil Catalog fails closed; tests for the telemetry adapter and the unreachable branches of verifyMentioned; repeated literals became constants (Sonar go:S1192); the in-package scan also finds parenthesized conversions. Not changed: a gateway or channel op registered by two modules stays accepted, and the Semgrep rule keeps the limits the authz README lists. The brief asks the two-module rule only for event types, and a gateway op can legitimately be handled by one module per role later; type-aware checks of every implicit conversion are beyond Semgrep, and every such value still fails closed.
M1-25/11 2026-10-12 TestServeReadyAfterStart (cmd/bothy/serve_test.go, M1-11) waits within the deadline of child.waitLine for the line server ready and for the public listener line instead of reading the child's stdout once after /readyz passed. This test file is outside the scope of the brief. The full make check of this ticket failed once on it with both lines missing from the buffer although /readyz already answered 200: the child's stdout arrives through a pipe, so a line written before readiness can still be in flight. It passed 12 of 12 runs alone; the change removes the read-once race without changing what the test proves.
M1-25/12 2026-10-12 Port onto main: tools/catalogen reads the English catalogue of main. The 05 texts it interprets are English (yes, no, denials for the cell "Audit", conditional and fresh for step-up, none for the class, as the target action for the resource), and the German alternatives of the brief's normalization table are gone, because actions.yaml on main has none; the fixtures and the generated catalogue are English. A resource text may end with a note after a semicolon outside parentheses (thread; for a hint without a thread the recipient (\user`)ofnotification.created, which main gained after the ticket was built); the backticked words of that note are further kinds, sonotification.creatednamesthreadanduseras R-AZ-30 says, and a note with unbalanced parentheses or backticks or a backticked word that is no kind is a finding (TestParse_Normalisation, eventnotice.created;TestParse_MoreFailures). Themisspellexclusions for the generated catalogues are gone with the German text. | Without the port the generator failed on the real catalogue (TestRealCatalogue,go generate`); the behavior for every entry the ticket knew is unchanged.

M1-27 Grant model storage

No Date Ruling Reason
M1-27/1 2026-10-12 Branch feat/M1-27-authz-grant-store from the head of int/m1-wave4 (76b79a5), in its own worktree; this session does not merge it locally and does not remove the worktree, the PR lane ports it. The launcher's instructions take precedence over the merge step of the brief.
M1-27/2 2026-10-12 The domain types carry every column of their table: Grant and Membership also have ExpiryReportedAt, Group also has ProviderID. NewGroup creates local groups only; claim groups come from the sync of M1-42. The brief's sketches omit these fields, but the store reads whole rows; dropping a column on read would lose data that M1-30 (expiry report) and M1-42 (claim groups) need.
M1-27/3 2026-10-12 NewGrant reports capability errors (unknown bit, terminal off a host, a host grant without exactly terminal) on /capabilities, and a host grant to an agent or app on /subject; the other fields are those of the brief. NewGroup(id, name, description, limits, by, at) takes GroupLimits (group.name_max_chars, group.description_max_chars, bound by M1-48), and names, descriptions and grant reasons pass the SEC-IN-4 text check (kernel/textcheck) like display names. The brief names five pointers but its own shape rules include capability rules; /capabilities is the request field that carries them. Configuration only through named keys; a reason is shown in the audit view and in "who has access", so it gets the same text rules as other display text.
M1-27/4 2026-10-12 bothy_app gets SELECT, INSERT, UPDATE on groups, group_memberships and grants, no DELETE. The immutability trigger does not block deletion; a retention job of M7 that must delete grants it in its own migration. Groups are soft deleted, memberships removed and grants revoked; the rows are history (R-DM-09). Least privilege, as M1-26 did for principals and owners; the repository has no delete query anyway.
M1-27/5 2026-10-12 authz_grants_immutable compares to_jsonb(NEW) and to_jsonb(OLD) without the five bookkeeping columns, then checks the revocation (once, with revoked_at set) and each expiry column (NULL to a value, once). An update that changes nothing passes. The adapter maps the trigger to validation_failed on /id. Comparing the whole row makes a column that a later migration adds immutable by default instead of silently mutable. A no-op is no change. The repository never reaches the trigger (its revocation filters revoked_at IS NULL), so the mapping only covers a programming error, and the brief maps trigger violations to validation_failed.
M1-27/6 2026-10-12 Repository writes store the times the caller passes (created_at, added_at, granted_at, deleted_at, removed_at, revoked_at) and never fall back to the column default now(); a zero time is kind internal. One clock decides validity (brief "Outgoing port"); a default would mix the database clock with the server clock of authorize.
M1-27/7 2026-10-12 Outside the file scope of the brief: architecture/modules.yaml gives authz the adapter vendor pgx (.go-arch-lint.yml regenerated), and sqlc generate regenerates the models of identity and instance, because every entry of sqlc.yaml reads the whole migrations/ directory; migrations/privileges_test.go gets the rows of the three tables. make arch, make codegen-check and TestPrivilegesAfterAllMigrations fail without them; M1-26 changed the same files for the same reason.
M1-27/8 2026-10-12 Names: the checks on groups are groups_source_check, groups_provider_shape_check and groups_external_ref_shape_check; the indexes are groups_live_name_key, groups_claim_key, group_memberships_active_key, group_memberships_active_user_idx, group_memberships_expiry_idx, grants_active_<column>_idx and grants_expiry_idx. A host grant to an agent violates both grants_bot_limits_check and grants_host_shape_check; PostgreSQL reports the first by name ("When a table has multiple CHECK constraints, they will be tested for each row in alphabetical order by name", CREATE TABLE, retrieved 2026-10-13), and both map to a client error. The adapter maps by name, so the names are part of the contract and TestAuthzGroupsGrantsSchema holds them.
M1-27/9 2026-10-12 Additions beyond the sketched API: CapsOf, Capability.String, Grant.Shape and MembershipWithGroup in domain. CheckRevoke does not apply rule 1 of CheckCreate. They keep the use cases of M1-28, M1-29 and M1-48 from rebuilding sets and shapes by hand. Rule 1 judges a grant that the database check never lets exist, and an owner must be able to revoke anything (brief: "owner always allowed").
M1-27/10 2026-10-12 Port onto main, after M1-24: both tickets added migration version 4, so the migration of M1-27 becomes 00005_authz_groups_grants.sql with unchanged SQL; migrations.sha256, the authz README, the server CHANGELOG and authzVersion in migrations/authz_groups_grants_test.go follow. capNames (caps.go) and levelNames (levels.go) carry a //nolint:goconst with its reason, because goconst counts per package and the generated catalogue metadata of M1-25 in the same package repeats manage and read. References name the English chapter files and headings of main (comment headers of the queries and the migration, so the checksum is recorded anew). goose refuses a set with a duplicate version, and generated files are only excluded from the goconst report, not from its count (both observed on the stack on 2026-10-12).

M1-19 Rate limits per class

No Date Ruling Reason
M1-19/1 2026-10-12 Rounding of the headers: r = whole tokens left after the decision, rounded down; t = seconds until full, rounded up, 0 when full; Retry-After = seconds until one token, rounded up and at least 1. Tokens are fixed point (1/60e9 of a token, refilled by per_minute units per nanosecond), so every value is an exact integer division. Recorded in 06 "Rate limits" point 5. Rounding down r and up t and Retry-After never promises a token that is not there; integer arithmetic makes the exact header tests possible without float drift.
M1-19/2 2026-10-12 A principal, agent or host class counts an anonymous request (or one without an actor) by its client IP with the same normalisation as rl.public_auth (IPv6 by /64, IPv4-mapped unmapped). Keys carry a kind prefix (ip:, p:, a:, h:, d:, t:, c:, w:), and the internal key is <class>\|<key>. Recorded in 06 "Rate limits" point 6. Unauthenticated calls must neither share one bucket (one client could starve all) nor fall into a principal's bucket; the prefixes keep the IP fallback and the principal apart within a class.
M1-19/3 2026-10-12 rl.webhook_in is keyed by the path value webhook_id of POST /api/v1/hooks/{webhook_id}/{token}, never by the token; the start check refuses an operation of this class without {webhook_id} in its path. 05 counts per incoming webhook; the ID names it, while the token is a secret that must not sit in memory as a map key or show up in a key derivation. Someone who knows a webhook ID can exhaust its bucket before the token check; that is the price of counting before the use case and matches the purpose of the class (bounding a flood on one webhook).
M1-19/4 2026-10-12 Eviction: at most ratelimit.max_tracked_keys buckets in a fixed number of shards (at least minPerShard per shard, at most maxShards shards, limiter.go; shard by hash/maphash of the internal key with a random seed per limiter, instead of the FNV-1a the brief names, which a client can compute: hash/maphash "Each Seed value is local to a single process and cannot be serialized or otherwise recreated in a different process", retrieved 2026-10-13); the per-shard cap is the cap divided by the shards, rounded down, so the total never exceeds the key. An insert into a full shard removes every full bucket of a random sample of evictSample (Go map iteration order, which the Go specification leaves unspecified, retrieved 2026-10-13), else the sampled bucket with the oldest last use. Sweep removes full buckets every ratelimit.sweep_interval. The limiter never denies because the table is full. Every REST key has a bounded length (IP, prefix or ID; a webhook_id that is not an ID is 404 before a bucket exists). Residual risk, accepted: one client that can create many distinct keys within one refill window (many webhook_id values or many IPv6 /64 prefixes) can make the limiter evict non-full buckets, its own included, and so reset them; the random seed means it has to flood every shard instead of one, that is more than ratelimit.max_tracked_keys fresh keys per refill window. A full bucket decides like a missing one, so removing it changes nothing; the cap must survive a flood of keys without unbounded memory, and a limiter that denied on a full table would let that flood lock out every client. The seed and the key length check follow the red team of M1-19 (finding 2); a shared table per client would need a second limit layer that 05 does not define.
M1-19/5 2026-10-12 The failed-credentials rule needs a check before the Authenticator, which the hook of M1-18 (called only after a failure) cannot give. platform/httpserver gets Chain.SetCredentialsPrecheck (runs only for requests with an Authorization header); its function returns a settle function that the stage calls with the result of the Authenticator. ratelimit.Install sets only the precheck; SetFailedCredentialsHook of M1-18 stays for other uses. The precheck reserves one token of rl.public_auth with Allow; an empty bucket is 429 with RateLimit-Policy and RateLimit of rl.public_auth. Settle keeps the token for a failure with 401 other than token_expired and use_dpop_nonce and returns it with Limiter.Refund otherwise (success, those two codes, an error of another kind such as 503 of the identity store). At most burst verifications of one client IP run per refill; more than burst requests with credentials of one client IP in flight at the same instant get 429 even with valid credentials. 02 "Chain in the api node" bounds the signature checks a failing client can cause; a precheck that only looked let concurrent requests all pass on one token (red team M1-19, finding 1). A server failure is not the client's fault and must not lock out an IP; returning the token keeps valid credentials free of cost. The in-flight limit is the price of the bound and is far above a normal client's parallelism at the default burst.
M1-19/6 2026-10-12 Files of platform/httpserver outside the brief's scope change: Operation.RateLimitClass (copied by wire.HTTPOperation, held by TestHTTPOperationCarriesEveryField), SetCredentialsPrecheck, and httpservertest.Options.Setup, which runs on the chain before the routes are mounted so tests install the limiter as internal/wire does. platform/ratelimit depends on httpserver and otel in architecture/modules.yaml. The stage must read the class of the route from the context, and routes.Operation cannot be imported by the platform layer (M1-18/2). The harness had no way to set hooks before Wrap seals the chain.
M1-19/7 2026-10-12 The key kind table is hand-written in ratelimit/classes.go, because actions.yaml holds the key column only as prose; TestKeyKinds_MatchChapter05 parses the generated table of 05 and maps each wording to a kind. The kind list of the brief gets KeyHost for rl.host ("host"); rl.terminal and rl.import are principal classes as the table says (the person, the owner). The brief allows the hand-written table with this test; without KeyHost the class of the host service would have no key.
M1-19/8 2026-10-12 Bounds of the keys: ratelimit.<class>.per_minute and .burst from 1 to 1000000 (ratelimit.MaxLimit), ratelimit.max_tracked_keys and ratelimit.sweep_interval within the bounds of their schema entries (configkeys_ratelimit.go). A value outside stops the start with exit code 2 (configuration error); ratelimit.New checks the same bounds again. Zero would disable a class or the sweeper (a ticker needs a positive interval); the upper bound keeps burst x 60e9 far inside int64.
M1-19/9 2026-10-12 The middleware fails closed: an operation without a class, with an unknown class, with a gateway class (rl.typing, rl.gateway_connect) or without a key answers 500 internal_error and never reaches the handler; the start check of internal/wire (InstallRateLimits) refuses these operations before the listener opens. bothy.ratelimit.rejected counts every denial of Allow (REST, gateway, failed credentials) and every refusal of the precheck. Serving an operation without a limit would bypass the requirement silently. One counting point keeps the metric complete for the gateway tickets.
M1-19/10 2026-10-12 Responses written before StageRateLimit (a 401 of StageAuthenticate, the precheck 429 aside) carry no RateLimit headers of the route's class. The order of 02 "Chain in the api node" puts authentication before the rate limit; the class key of a principal is known only afterwards.
M1-19/11 2026-10-12 Branch feat/M1-19-rate-limits from int/m1-wave4 (76b79a5); this session does not merge it locally and does not remove the worktree, the PR lane ports it. Text added to the German chapter 06 is English. The launcher's instructions take precedence over the brief's merge step; everything added is English (M1-23/9).
M1-19/12 2026-10-12 A webhook_id that is not an ID (06: UUIDv7) gets 404 not_found in StageRateLimit, before the validator, which would answer a malformed path parameter with 400; the key uses the canonical form of the ID. A panicking Authenticator settles the reserved rl.public_auth token like a server failure (returned), then the recover step answers 500. For M1-34: everything except a counting 401 returns the token, so the identity ticket must report a failed expensive proof as a counting 401, else the rule only bounds concurrent proofs. The stage must not create a bucket for a value that names no webhook, and an unknown webhook is a 404 in the use case as well, so the answer leaks nothing; red team M1-19 round 2 found the 404 path, the panic path and the M1-34 dependency.
M1-19/13 2026-10-12 Port onto main: TestKeyKinds_MatchChapter05 maps the English words of the column "Key" of 05 "Rate-limit classes" (client IP, device or token, principal, connection, agent, incoming webhook, host) instead of the German ones; references name the English chapter files and headings of main; the planned rule ratelimit. leaves tools/configdoc/planned.yaml; the jobs.go row of the wire README names MigrateUpSteps (ruling M1-15/16). Translation and conflict resolution only; the test still holds the hand-written map equal to the chapter table.

Test container cleanup (pgtest)

No Date Ruling Reason
PGT-1/1 2026-10-13 The pgtest container removes itself when its test binary is gone: the binary holds a lease connection, a watchdog script in the container stops PostgreSQL once the lease is missing for leaseMisses seconds, and AutoRemove removes the container with its volume. Once the lease was taken, leaseStartGrace without a readable answer stops a hung server as well, and the stop waits at most leaseStopWait before the script exits. A run label with an exit hook was not used. No code in the process runs on SIGKILL or on the -test.timeout alarm, and Ryuk is off; only a check from outside the process covers every way a run ends, and the operating system closes the connection of a process however it ends.
PGT-1/2 2026-10-13 pgtest.Main removes every container labelled org.bothy.test (any value) without the label org.bothy.test.watchdog once it is older than legacyReapAge (2 hours), with its volumes. This also covers the SeaweedFS helper of the files tests (org.bothy.test=seaweedfs), which is not on main yet; that helper may adopt a watchdog of its own when it lands. One reaper for every labelled test container that cannot remove itself; a default go test run ends after 10 minutes (-timeout: "The default is 10 minutes (10m).", go test flags, retrieved 2026-10-13), so a container that old belongs to a finished run.

M1-28 Permission snapshot, cache and authorize

No Date Ruling Reason
M1-28/D4 2026-10-12 Visibility per resource kind as the reading of 07 section 4.3 for resources outside the structure tree: instance for every authenticated principal or a Public action; space with read on the space or on one of its channels; channel-path kinds (channel, thread, message, reaction, pin, run, run_step, component, interaction, agent_session, file, incoming_webhook, command) for dm participants only in a dm channel, otherwise with read on the path; grant for owners or with read on a space or channel target (instance and host targets: owner only); agent for owners, the agent itself or read on a channel it operates; user and principal for people and apps, or the principal itself; group for owners or members in effect; device, auth_session, api_token, passkey, push_registration, draft, gdpr_request and dev_environment for the owner of the resource, or an effective owner when the requirement names owner; app, webhook_delivery, import_source and host for owners. Any other kind (terminal_session included) is an internal error. Invisible is not_found with no_grant, not_self for the principal-owned kinds and acting_for_insufficient when only the acting-for intersection removed the visibility. On a space, Level(read) means "space visible". 07 section 4.3 lists the rules for the tree and names the owned resources only by example; one table in domain/visibility.go makes every kind explicit and fails closed for a kind it does not know. The space rule was found by AZ-P8: a person who reads only a channel of a space must see the space (05 space.read).
M1-28/D5 2026-10-12 The snapshot keeps the direct grant maxima per target (instance, space, channel), the owner role and the groups in effect; propagation along the path, dm participation, the operator rule and the limits of agents and apps are applied when a path is evaluated, and Scope builds its lists per request from the snapshot and the current structure. 07 section 7.2 ("The snapshot stores levels per target"): a channel or space created without a new permission version is judged and listed at once, and DM participation read from the resolved channel covers a dm created after the snapshot (TestChannelCreatedAfterSnapshotVisibleWithoutNewVersion, AZ-P8).
M1-28/1 2026-10-12 kernel/authorize.Authorizer takes an authorize.Principal, an interface of plain values (PrincipalID, PrincipalKind, SessionRef, DeviceRef, TokenRef, InstallationRef, StepUpTime, AppGrantedScopes, ViewAsPerson, ActingForPerson) that kernel/actor.Actor implements; callers pass their actor. The brief's signature names actor.Principal, but kernel ports never import each other (02 section 7.2; make arch rejects kernel/authorize importing kernel/actor, checked). The interface keeps the call site of the brief (az.Authorize(ctx, actor, ...)) without a cross import.
M1-28/2 2026-10-12 actor.Actor.Scopes stays the token scopes; the installation scopes are the new field GrantedScopes, read by the scope check (D6). The part "token scopes are not evaluated" of D6 is superseded by D-M2-24-10. The brief asks for GrantedScopes and forbids renaming existing fields; 07 section 3 separates token_scopes from granted_scopes.
M1-28/3 2026-10-12 A person in status pending gets forbidden principal_pending (problem code device_pending) except for the actions a pending device may run; disabled and deleted give unauthenticated principal_inactive. A pending device passes device.list through the pending gate; after the visibility check any device.list other than on the own device is forbidden device_pending, so a foreign device stays not_found. The brief's liveness line maps every status other than active to principal_inactive, but 07 sections 4.1, 4.2 and SR7 and 05 section 2.5 give pending people principal_pending; the chapter wins over the sketch. The device order lets the brief's test "device.list on another device is not_found" hold without leaking a foreign device.
M1-28/4 2026-10-12 run.token.renew of the brief's deferred list is not in the catalogue, so it has no requirement entry; typing.start ("human and write", not in the brief's text table) reads All(Human, Level(write)). TestRequirementsCoverCatalogueExactly allows no entry outside the catalogue; the expression is the literal reading of 05 section 2.1 ("and" is both).
M1-28/5 2026-10-12 The start check rejects deferred actions in routes, gateway ops, terminal channel ops and event types (violation kind deferred_action). The M1-25 test no longer registers host_control/session_open as valid, because every terminal channel op names a deferred action of M2. 07 section 4.2 names routes, ops and event types; a registration that names a deferred action could never be authorized.
M1-28/6 2026-10-12 StructureSource has one method beyond the brief, Spaces(ctx), so Scope lists the spaces of an instance-wide reader and of a space grant also when a space has no channel yet. Without it a new empty space would be missing from fully_visible_space_ids of owners (07 section 7.2 instance_wide).
M1-28/7 2026-10-12 Authorize also fails closed with an internal error when the resource kind is not one of the action's resource kinds (access.preview: those of the previewed action), when agent.invoke has no Invoke facts and when grant.create names a target other than its resource. A principal whose kind differs from its principals row is unauthenticated. Scope audits a liveness denial like Authorize. 07 section 1 point 5 and the brief: no unknown resource kind and no contradiction may allow.
M1-28/8 2026-10-12 The source-ordered tables (requirementGroups, appScopes, the kind and pending lists) carry //nolint:goconst with a reason, and the requirement table also carried misspell for the German 05 texts until the port onto the English main (M1-28/13); the table is grouped by 05 text so every text appears once in its file. goconst counts per package and includes the generated catalogue; the analysis counts per file (go:S1192), which the grouping satisfies. Precedent INT-W5/6 and M1-25/7.
M1-28/9 2026-10-12 TestAZP3_UnknownActionFailsClosedThroughAuthorize carries the brief's AZ-P3 case through Authorize; the M1-25 test TestAZP3_UnknownActionFailsClosed of the catalogue stays. cost.reconcile.read was not in the scope table while it was not in the catalogue; since main has it (05, R-AZ-28), the port adds its row (M1-28/13). Both test names match ^TestAZ; the brief allows the row only when the catalogue has the action.
M1-28/10 2026-10-12 Base int/m1-wave5 at 46d20a0, branch feat/M1-28-authz-core in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in the module READMEs (their German headings stay). Instructions of the launcher, which override the brief's merge step and the brief's language.
M1-28/11 2026-10-12 Red team round 1: the expected output of TestConfigPrintShowsSourcePerKey (cmd/bothy, outside the brief's file list) names the two new keys; a dm is visible only with read after the "view as" and acting-for intersections (also in the atom Participant); Self and the visibility rules share one meaning of "own" (OwnerID, or ID for the kinds whose id is a principal); adapters/fake has its own tests. The new schema keys change bothy config print; "view as" must never open a dm the viewed person is not in (SR6), and two meanings of "self" would trap later callers.
M1-28/12 2026-10-12 Session and token liveness are cached with the snapshot per permission version (04 section 8.5), so the identity tickets that revoke sessions, devices and tokens or log out (M1-33, M1-34, M1-44) must raise permission_version in the same transaction; this ticket changes no identity action. Without a new version a revoked or logged-out session stays live in the cache until the snapshot expires; auth.logout has "Permission" no in 05, so its ticket raises the version itself (red team round 1, minor 5).
M1-28/13 2026-10-12 Port onto main: the requirement table keys its rows by the English text of the column "Permission" of 05, as the catalogue of main carries it (TestRequirementSourceMatchesCatalogue). Main's catalogue gained entries after the ticket was built: run.token.renew ("agent-self and token current") is deferred to M2 like run.start, which supersedes the first half of M1-28/4; webhook_out.transition joins the system row; cost.reconcile.read ("owner or app with scope costs:reconcile") reads Any(Owner, AppKind) with the app scope costs:reconcile; the reason list gains terminal_no_session_user after i6_violation, as chapter 07 section 4.1 lists it. Comments say "milestone" for the former German name of the field. Each new row is the literal reading of its 05 text with the atoms the ticket already has; the goldens writes.golden and deferred_actions.golden follow.

CodeRabbit review tuning

No Date Ruling Reason
CRT-1/1 2026-10-13 Owner: CodeRabbit reviews with profile: chill instead of assertive, and a general path instruction for ** asks it to report bugs, security issues, broken contracts and problems that break CI, without style, wording or formatting nits. The schema has no general review instruction outside path_instructions (tone_instructions sets the tone of reviews and chat, at most 250 characters), so the rule is a path instruction for every file. Only Blockers and reproduced Majors block a merge; Minors are not polished. The assertive profile gives "more feedback (which may feel nitpicky)", chill "balanced feedback" (schema v2, retrieved 2026-10-13), and its nits cost review rounds without changing behavior. tools/ci/tests/test_coderabbit_config.py holds the profile and both instructions.
CRT-1/2 2026-10-13 Owner: the path instruction for **/*.md keeps English only, no en or em dashes, no instance-specific or private data, glossary terms and named configuration values instead of bare limits; it asks for a source URL and retrieval date only for claims about external systems that the specification or the security documents rely on (docs/spec/**, docs/security/**, docs/adr/**). Every other setting of .coderabbit.yaml is unchanged. Most CodeRabbit comments asked for a URL and retrieval date on every external fact in any Markdown file, which cost one to two hours per pull request; the sourcing rule matters where the specification and the security model build on the fact.
CRT-1/3 2026-10-19 Steering: the knowledge base stores learnings for this repository (knowledge_base.opt_out: false, learnings.scope: local, issues and pull requests local, Jira, Linear and MCP disabled). The general instruction for ** names settled facts that are no findings: lock files, go.sum and rendered SVGs are excluded on purpose, the default theme is dark, the pre-push hook and tools/conftest.py clear the git variables centrally, and test names and loopback or documentation addresses in tests are fine. The Go instruction limits "SQL only through sqlc" to data access queries; LISTEN/NOTIFY and advisory locks are not data access. With opt_out: true CodeRabbit answered hundreds of replies with "learnings detected but not stored", so the same explained comment came back on every pull request. The named facts were the most repeated comments that had to be answered by hand. tools/ci/tests/test_coderabbit_config.py holds each setting.

M3-01 Object storage port with file system driver

The brief names these rows D-M3-01-1 to D-M3-01-13; they follow the numbering of this log (M3-01/1 to M3-01/13), and M3-01/14 onwards are rulings of the build.

No Date Ruling Reason
M3-01/1 2026-10-12 ObjectStore is an outgoing port of the application layer in internal/files/app, with its value types (ByteRange, ObjectInfo, Part, PresignedPart) and errors. The root package internal/files stays the incoming port for other modules, which never see ObjectStore. Key schema and key grammar are pure functions in internal/files/domain. The conformance suite is the test helper package internal/files/adapters/filestest, imported only by _test.go files; TestFilestestNotInBinary holds it out of cmd/bothy. 02 "5. Layers within a module": the application layer holds outgoing ports as interfaces, adapters implement them; 02 "6. Code layout" places test helpers in adapters/<module>test.
M3-01/2 2026-10-12 Error contract of the port: ErrInvalidKey (the same value as domain.ErrInvalidKey, checked before any storage call), ErrNotFound, ErrInvalidRange, ErrSizeMismatch, ErrUploadNotFound, ErrInvalidPart (the upload stays open, the list shape is checked before any service call), ErrNotSupported. Delete of a missing object and AbortMultipart of an unknown, aborted or completed upload return nil. Every other error is a storage failure (503 unavailable); errors wrap the cause, name operation and key, never contain object bytes, and a refused key is not repeated. Recorded in 12 "4.1". 12 "4.1" and "12": a missing object counts as deleted, so retried cleanup jobs converge; callers decide 503 against a client error by sentinel only, and a key a caller passed may carry anything.
M3-01/3 2026-10-12 Get takes at most one byte range {Offset, Length}; a range whose end lies beyond the object ends at the last byte, Offset < 0, Length < 1 and Offset at or beyond the size give ErrInvalidRange. 12 "9.3" supports exactly one range, and an HTTP range past the end is cut there; an offset beyond the end has no byte to return (416 for M3-05).
M3-01/4 2026-10-12 ValidateKey is one grammar for all drivers: segments of [a-z0-9-] joined by single /, no leading or trailing /. filepath.Clean(key) == key holds for every accepted key; FuzzValidateKey holds the grammar. ADR 0010: the key schema is the same in both drivers, so a key that works on one works on the other, and no key reaches outside the root.
M3-01/5 2026-10-12 On the file system CreateMultipart accepts only original keys, creates uploads/<file_id>/, is idempotent and returns the upload ID ""; UploadPart and CompleteMultipart need the ID "" and that directory, else ErrUploadNotFound. While an upload is open, its parts are regular objects under uploads/<file_id>/<n>; the conformance suite never relies on that. The driver takes no lock. 03 file_upload_sessions.storage_upload_id holds an upload ID only for the S3 driver, and the place of the parts follows from the key (12 "3.2"); clients upload only originals in parts; the session row of M3-03 serializes completion against late parts.
M3-01/6 2026-10-12 The file system part ETag is the lower-case hex SHA-256 of the part bytes in double quotes, computed while writing; CompleteMultipart reads each listed part exactly once into one temporary file, checks its hash against the ETag (ErrInvalidPart on a mismatch, the upload stays), and removes uploads/<file_id>/ with all parts, listed or not; a failure of that removal after the rename does not fail the completion. UploadPartResult.etag is required for both drivers, and the form equals the ETag header of 12 "9.3"; one read per part follows R-FI-04; S3 also discards unlisted parts; a retry after a reported cleanup failure would find parts missing and refuse a finished object, while the Delete of the original removes leftover parts (M3-01/18).
M3-01/7 2026-10-12 Temporary files of writes lie in <root>/.tmp with mode 0600 and a random name; every failed write removes its own. .tmp starts with a dot, which no key may contain, so no key addresses a temporary file; the same file system makes the rename atomic.
M3-01/8 2026-10-12 The store keeps an os.Root of the root and in addition follows no symbolic link at all: each existing path component is checked with Lstat through the root before an operation and again before the rename. os.Root follows a link inside the root even with O_NOFOLLOW (Go rootOpenFileNolog resolves ELOOP itself, except with O_CREATE\|O_EXCL), so a read compares the opened file with the checked one (os.SameFile) and on a difference checks the path again: a link there is a storage failure, another regular file is a concurrent replace and a valid read (TestGetRefusesLinkSwappedBeforeOpen). Unix-only file; on other systems Open refuses every root. A link found is a storage failure, not ErrNotFound. 12 "4.2": the driver follows no symbolic link; os.Root alone keeps operations inside the root but would follow links within it; a storage failure makes the cause visible to the operator. Residual window: a process that can write into the root could swap a component for a link between the check and the call; os.Root still keeps that inside the root, and only the server user can write there (start check). Such a process could also put a link in and replace it by a regular file again between the open and the second check; the read then returns the bytes of the link target, still an object inside the root.
M3-01/9 2026-10-12 fs.Open checks the root: absolute and clean, exists, Lstat shows a directory and not a link, owned by the effective user, permission bits exactly 0700 (no setuid, setgid or sticky bit), the opened handle is the same directory as the checked one. It creates <root>/.tmp with mode 0700 when missing, refuses a .tmp that is not a directory of the same user with mode exactly 0700, never creates the root and never removes entries of .tmp. Errors name file.fs.root and the expected state, never the path. The same start check as a server-owned key directory (M1-32); a missing root means a wrong mount, and entries of .tmp may belong to another process on the same root.
M3-01/10 2026-10-12 Put on an existing key replaces it atomically; the drivers do not enforce "Objects are never overwritten". The module enforces it through the key schema (a changed original gets the next revision) and the unique files.object_key; a retried job may rewrite a key that no committed row names yet.
M3-01/11 2026-10-12 Delete removes the file, syncs the directory, then removes f/<aa>/<bb>/<file_id> or uploads/<file_id> if it is empty now ("not empty" and "already gone" are ignored); the fan-out levels stay. A writer whose directory vanished before the rename creates it again and renames again (see M3-01/15). Deleting the last part of an open upload removes uploads/<file_id>/ and so ends the upload. Without it every deleted file would leave a directory behind, and the server never lists to find them (12 "4.1").
M3-01/12 2026-10-12 Cross-check file_fs_root_absolute on file.storage.driver and file.fs.root: with fs the root must satisfy filepath.IsAbs and filepath.Clean(root) == root; with s3 nothing is checked. Recorded in 02 "13. Configuration loading" item 4. A relative root would depend on the working directory of the process; with s3 the root is unused.
M3-01/13 2026-10-12 wire.FilesObjectStore refuses the driver s3 with config: file.storage.driver: driver s3 is not supported by this server version until M3-02 replaces the branch. No role calls FilesObjectStore yet. As M1-07 did for the secret schemes azkv and awssm; M3-03 opens the store in the roles together with the image directory, because opening it at start now would stop the stack.
M3-01/14 2026-10-12 Branch feat/M3-01-object-storage-fs from int/m1-wave5 (46d20a0); this session does not merge it locally and does not remove the worktree, the PR lane ports it. Text added to the German chapters 12 and 02 and to ADR 0010 is English, as is the README addition of files. The launcher's instructions take precedence over the brief's merge step and over "German like the chapter"; everything added is English (M1-23/9, M1-19/11).
M3-01/15 2026-10-12 The rename of a write repeats (directory created again) as long as it fails because a concurrent Delete removed the emptied file directory, instead of the single retry of the brief; it stops when the rename succeeds, fails for another reason, the temporary file is gone or the context ends. "Vanished" is ENOENT, EINVAL from the rename, or a directory that is missing now; mkdirs creates a component again that vanished between Mkdir and Lstat. A directory sync after the rename that finds the directory gone is no error. TestTwoStoresShareRoot (two stores, parallel Put, Get and Delete on keys of the same files) failed with a single retry on macOS: renameat returns EINVAL, not ENOENT, when a concurrent rmdir removes the target directory during the call (reproduced in a probe), and two removals can fall into one write. Each repetition needs another delete that empties the directory in that window, so no count is needed; a count would be a threshold without a configuration key.
M3-01/16 2026-10-12 *fs.Store has Close (releases the root handle) besides the port; FilesObjectStore returns the store as filesapp.ObjectStore, which implements io.Closer for the driver fs, and never a typed nil. The os.Root holds a file descriptor; the port has no lifecycle operation (12 "4.1"), so the owner of the store (M3-03) closes it through io.Closer.
M3-01/17 2026-10-12 Every directory a write creates is synced in its parent (TestNewDirectoriesAreSynced), beyond the sync of the target directory that the brief names. CompleteMultipart checks the shape of the part list before any file system call, also before the upload check, and the conformance suite holds it (an empty list on an unknown upload is ErrInvalidPart). Code review M3-01 (findings 2 and 3): without the parent sync a crash after a successful Put can lose the entry of a new file directory on file systems without ordered metadata; the list check first keeps both drivers equal (design note 3). A writer that finds a directory already created by a concurrent writer does not sync its parent again; the creator does, and an extra fsync per existing level on every write would cost more than the window between another writer's Mkdir and its parent sync.
M3-01/18 2026-10-12 Delete of an original key also removes uploads/<file_id>/ with any parts in it (TestCompleteSurvivesCleanupFailure), also when the object itself is already gone. Code re-review M3-01: when CompleteMultipart cannot remove the parts after the rename, nothing else would ever remove them (a completed session never expires, the server never lists), and deleting the file must remove all its bytes (12 "12", also for GDPR erasure). An upload in parts exists only for a file that has no committed original yet, and the delete of a failed upload wants its parts gone too.
M3-01/19 2026-10-12 Red team M3-01 (verdict JA, five minors): PresignUploadPart checks the part number (ErrInvalidPart for n < 1) before it answers ErrNotSupported, as the port contract says for every driver; TestKeyBuildersTakeOnlyIDsAndNumbers finds every exported ...Key function of internal/files/domain in the source, so a new builder cannot escape the parameter check. Not changed, by decision: an upload directory left by a failed cleanup after CompleteMultipart still accepts parts until the original is deleted (the session row of M3-03 refuses parts and completion after completed); a file system upload is bound to the file ID, not to the revision of the key (only orig-1 is uploaded in parts, 12 "3.1"); Put on a part key creates uploads/<file_id> (parts are objects, M3-01/5, and no caller writes part keys with Put). Design note 3 puts the part number rule into the port for both drivers, and design note 11 only forbids file system calls; the other three cases need a caller that M3-01 does not have, and guarding them in the driver would duplicate the session state of M3-03.
M3-01/20 2026-10-14 sonar-project.properties leaves the test helper packages services/server/internal/*/adapters/*test/ (here filestest) out of the coverage measure (sonar.coverage.exclusions); they stay in the analysis for issues. Go counts the coverage of a package only from its own tests, and a test helper is exercised by the tests of the drivers that import it, so its failure branches (t.Error calls) would read as uncovered new code and fail the gate on coverage of new code.

M3-02 S3 driver against SeaweedFS

The brief names these rows D-M3-02-1 to D-M3-02-13; they follow the numbering of this log (M3-02/1 to M3-02/13), and M3-02/14 onwards are rulings of the build.

No Date Ruling Reason
M3-02/1 2026-10-12 One egress client per store: purpose objectstore, a host policy that allows exactly the scheme, host and port of every request (the endpoint with path style, <bucket>.<endpoint host> with virtual-hosted style, the port of the endpoint or the default port of its scheme), RedirectNever, the time limit http.transfer_timeout and the body limit file.max_upload_bytes. 19 SEC-NET-2: a consumer names its own targets. 02 14.2 lets a chapter name the limit of an outside call, and these bytes serve uploads and downloads, whose limit http.transfer_timeout is. No stored object is larger than file.max_upload_bytes (03 files.size_bytes), so a body is neither cut nor unbounded (SEC-NET-5). An S3 redirect means a wrong endpoint or region.
M3-02/2 2026-10-12 The SDK client is built by hand with s3.New(s3.Options{...}): region, the credentials of M3-02/6, the HTTP client of the egress client, the checksum mode of M3-02/10, a no-op logger, no tracer or meter, BaseEndpoint from file.s3.endpoint, UsePathStyle, S3 Express session auth off. github.com/aws/aws-sdk-go-v2/config and LoadDefaultConfig are never used, and file.s3.endpoint is required. The brief named aws.Config plus s3.NewFromConfig; NewFromConfig looks up AWS_ENDPOINT_URL and related variables of the environment, s3.New takes only the options. The default configuration reads the environment and shared files and asks the instance metadata service, whose address SEC-NET-3 always blocks and whose credentials 12 4.3 does not allow. Without the endpoint resolution of the SDK the host policy names exactly one host. SDK spans and metrics have no names in signals.md (02 5 rule 5). The SDK still reads AWS_EXECUTION_ENV and the Lambda trace variables for the user agent and the recursion header; neither changes a target or a credential.
M3-02/3 2026-10-12 No SDK retries: aws.NopRetryer{}. A failed call is one request and a storage failure. 02 14.3: no retry of external calls in the request path; jobs retry through River. TestNoRetries holds one request for a 503 SlowDown.
M3-02/4 2026-10-12 Put and UploadPart sign their body as UNSIGNED-PAYLOAD (v4.SwapComputePayloadSHA256ForUnsignedPayloadMiddleware, present in aws-sdk-go-v2 v1.47.3) and stream it with ContentLength set, never buffered. The caller's reader is not seekable and the endpoint may be plain http in a private network, where the SDK cannot hash the payload before sending. Integrity comes from the part digest (12 3.2) and the file SHA-256 of the read pass (12 3.4), which 12 4.3 names as the replacement of SDK checksums.
M3-02/5 2026-10-12 Every error of the driver is one type with the text files/s3: <operation> <key>: <detail>; detail is the port sentinel, the S3 code with the HTTP status, a status, a fixed text for a known cause (egress refusal with its reason, response too large, context cancelled or deadline, credentials with the key and the secret error code) or transport. Unwrap returns the port sentinel and the cause. Mapping: NoSuchKey, NotFound or 404 on Get and Head is ErrNotFound (NoSuchBucket is a storage failure); InvalidRange or 416 on Get is ErrInvalidRange; NoSuchUpload on UploadPart and CompleteMultipart is ErrUploadNotFound and on AbortMultipart nil; InvalidPart, InvalidPartOrder and EntityTooSmall on CompleteMultipart are ErrInvalidPart; everything else is a storage failure. The brief wrote the prefix as s3 <operation>; the driver uses files/s3: like the file system driver's files/fs:. net/http errors carry the full request URL, and callers that wrap a port error into kernel/errs put its text into the log (SEC-LOG-3, SEC-SEC-3). The conformance suite gives the same results on both drivers. A HEAD answer has no body, so Head on a missing bucket reads as ErrNotFound; Get, Put and Delete report the bucket as a storage failure.
M3-02/6 2026-10-12 The credentials provider resolves both references through the secret provider on every Retrieve and returns credentials that expire at once, so the SDK credential cache asks again for the next request; the driver keeps no copy. New resolves both once and stops with an error that names the key. bothy config check --secrets resolves the two references only with file.storage.driver = s3 and prints skipped otherwise. 16 5.2 and 02 13 item 6: a rotation works without restart once the provider re-reads the file after secrets.cache_ttl; a failed re-read keeps the old value inside the provider (M1-09). 02 14.4 row Secret-Provider: fail fast at start. 12 18: the references are read only with the driver s3.
M3-02/7 2026-10-12 The object name is <file.s3.prefix>/<key> with a prefix, else the key; key and prefix both pass domain.ValidateKey before any request, and a refused key or prefix is never repeated in the error. M3-01/4: one grammar for all drivers, which refuses empty segments and a leading or trailing /, so a path-style URL never collapses two keys into one object (FuzzObjectName); the driver adds nothing else to a key (SEC-UP-8).
M3-02/8 2026-10-12 A body of another length than size gives ErrSizeMismatch and stores nothing: the body wrapper hands out at most size - 1 bytes until the source has delivered the last byte and reported its end. A longer source fails before the last byte leaves, a shorter one fails before ContentLength is reached; a size of 0 proves the reader empty before any request. M3-01 note 3. S3 never receives a complete request, so neither PutObject nor UploadPart can store a body of the wrong length (casePutSizeMismatch, TestS3UploadPartSizeMismatch against SeaweedFS).
M3-02/9 2026-10-12 The port operation is PresignUploadPart(ctx, key, uploadID, n, size, ttl). The S3 driver presigns UploadPart with ContentLength = size and the expiry ttl, and returns ExpiresAt = the time taken before signing plus ttl. n < 1 (or above 10000) gives ErrInvalidPart, size < 1 ErrSizeMismatch on both drivers; a ttl that is not a positive whole number of seconds and an empty upload ID are refused before signing. 12 4.1 is updated (chapter 12 is not core contract). Result of TestPresignedPartBindsLength (12 3.3): SeaweedFS at the pinned version accepts a plain PUT of exactly the signed length and refuses one byte more, one byte less, chunked transfer encoding and a PUT after the validity, each without a part. 12 3.3, R-FI-02, RK-42: no driver can bind a length it does not know, and X-Amz-Expires carries whole seconds. The mode presigned stays off (file.s3.presigned_upload is not registered); client reachability of the storage stays open.
M3-02/10 2026-10-12 file.s3.checksum_mode has the values when_required (default) and when_supported; they map to aws.RequestChecksumCalculationWhenRequired with aws.ResponseChecksumValidationWhenRequired and to the two WhenSupported values. when_supported is refused at load with an http endpoint (cross-check file_s3). 12 18 names the default but not the value set; the two values are the two settings of the SDK, the same for request and response as 12 4.3 says. With when_supported the SDK sends the checksum of a streamed, unseekable body as a trailer and refuses that without TLS, so every Put and UploadPart over http would fail (found in review; TestChecksumWhenSupportedOverHTTPS holds the https path).
M3-02/11 2026-10-12 Cross-check file_s3 over file.storage.driver, the six non-secret file.s3.* keys and egress.allowed_hosts, only with driver s3, through s3.Config.Validate: endpoint set and an absolute http or https URL with a host and without user info, query, fragment or a path other than /; bucket set and in the S3 naming rules for general purpose buckets (reserved prefixes and suffixes such as --x-s3 refused); region set; prefix empty or the key grammar; an IP endpoint host only with path style; an http endpoint only with an egress.allowed_hosts entry with allow_private for the request host and port. file.s3.endpoint is a string key. The check fills s3.Config from the six keys; it cannot bind the struct, because a check sees only the keys it names. New runs the same Validate. 02 13 item 4, every message names keys, never values. 19 SEC-NET-2 allows http only with such an entry; without it the server would start and fail at the first request. A directory bucket would make the SDK open a session with CreateSession, which is not core S3. The schema allows an empty default only for string and list keys.
M3-02/12 2026-10-12 Architecture rule: vendor group awssdk (github.com/aws/aws-sdk-go-v2, github.com/aws/smithy-go and their subpackages) and adapter_vendors: [awssdk] for module files; depguard rule aws-sdk-only-in-s3-driver denies github.com/aws/ in every file except **/internal/files/adapters/s3/**, production and test files alike; forbidigo forbids s3.Client.DeleteObjects and s3.PresignClient.PresignGetObject. A temporary SDK import in adapters/fs and temporary calls in the s3 package failed golangci-lint once and were reverted. 12 15, ADR 0010 and ADR 0048 Confirmation. go-arch-lint grants vendors to the component internal/files/adapters/** as a whole, so only depguard (ADR 0008, rules per file pattern) narrows the SDK to one adapter.
M3-02/13 2026-10-12 The SeaweedFS test container runs chrislusf/seaweedfs:4.48 pinned by the digest of its multi-architecture index, the repository and version of the Compose service seaweedfs (TestSeaweedImagePinned; Renovate moves it in the group Compose-Images through its own regex manager). It starts with the command of the Compose service (weed server -dir=/data -s3 -ip.bind=0.0.0.0, keys as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) plus -master.volumeSizeLimitMB=32 -volume.max=1000. Test code builds a full SDK client to create buckets and inspect state (ListParts, HeadObject); the driver never does. infra/compose/compose.yaml stays unchanged. The test proves the version the stack runs. SeaweedFS keeps every bucket in its own volumes, and with the default volume size of 30 GB the free disk of a test runtime held one bucket only (No writable volumes and no free volumes left), while every test creates its own bucket; the stack has one bucket. TestS3WrongCredentialsRefused shows that SeaweedFS with this command refuses a wrong secret key, so the Compose service needs no identity configuration.
M3-02/14 2026-10-12 wire.FilesObjectStore(ctx, values, wire.FilesDeps{Secrets, Egress}): driver fs unchanged; driver s3 binds s3.Config, builds wire.EgressConfig and calls s3.New with http.transfer_timeout and file.max_upload_bytes. A construction error is an error of FilesObjectStore that starts with config: and names the key. No role opens the store yet. M3-01/13: M3-03 opens the store in the roles together with the image directory. The S3 branch needs a context, the secret provider and the egress dependencies, which the old signature did not carry; no other egress consumer is wired yet, so FilesDeps.Egress is where the logger and metrics of the process go.
M3-02/15 2026-10-12 The ticket ends on the branch feat/M3-02-object-storage-s3 from int/m1-wave6 and is not merged locally; the worktree stays. The launcher of this build overrides the brief's "merge locally into the base branch, then git worktree remove": the integration of the wave merges the ticket branches.

M2-23 Generated TypeScript and Python clients

No Date Ruling Reason
M2-23/1 2026-10-12 Layout and names: Python distribution bothy-sdk, import package bothy_sdk in sdk/python/src/bothy_sdk/, generated core in the subpackage bothy_sdk.generated; npm package bothy-sdk ("private": true, "type": "module"), generated types in sdk/typescript/src/generated/openapi.ts. pyproject.toml carries Private :: Do Not Upload. The Python project has no build backend yet ([tool.uv] package = false; tests and mypy read src/); the release chain of the SDK components (M7) adds it. The names are a new kind in 02 6 "places that carry the product name". A directory named generated is already excluded by Sonar and exempt from the SPDX header check, so no gate configuration changes. Working names until OQ-17; V1-06 renames them with the other listed places. A build backend would be a further unlocked download (build dependencies are not in uv.lock) for an artifact nothing builds before M7.
M2-23/2 2026-10-12 Generator pins live in the components: openapi-python-client==0.29.1 in the dev group of sdk/python/pyproject.toml (uv.lock), openapi-typescript 7.13.0 exactly in devDependencies of sdk/typescript/package.json (package-lock.json). The fallback to tools/pyproject.toml was not needed: the license gate passes with three PackageOverrides (M2-23/13). test_toolchain_doc.py reads both pins from the SDK locks and checks that 06 14 names them and openapi-fetch. Renovate groups them as "SDK code generators". Like oapi-codegen in services/server/go.mod: the generator version belongs to the code it produced, Renovate updates it next to that code, and the license and OSV gates see it.
M2-23/3 2026-10-12 Both SDKs are generated from the full spec (all operations under paths), with no filter by services/server/api/operations.yaml. test_g4_every_operation_is_generated fails if the Python generator skips an operation. P-04 governs what the server registers; 11 11.1 asks for models and calls of all REST operations. The Python generator warns for five responses it cannot type (NDJSON, images, zip; 06 14) but generates every operation.
M2-23/4 2026-10-12 Python tolerance by an import hook without custom templates: bothy_sdk.tolerance.install() (idempotent, called by bothy_sdk/__init__.py) gives every generated enum a _missing_ hook returning a pseudo member with the raw value, wraps every model's from_dict to drop None members, and lets EventPage.from_dict drop items whose type is not in KNOWN_EVENT_TYPES (also non-objects). The one-member type enum of every model composed from EventEnvelope stays strict. KNOWN_EVENT_TYPES and the strict enums are derived from the generated code (field annotations), and tests hold them equal to the spec. test_event_union_is_the_only_union_of_the_spec fails when the spec gains another oneOf. 06 13 point 3 and 14 "Standard", R-EX-10. A tolerant type would let the union parser of the core accept an unknown event as the first model whose payload fits (the fixture future.example fits thread.moved_out). The same risk would exist for the discriminator of any new union, so the guard forces that decision instead of letting it slip.
M2-23/5 2026-10-12 Deterministic generation: the post hooks of openapi-python-client are set explicitly to ruff of the locked dev group with --isolated --no-cache --target-version py311; as the last step make generate rewrites U+2013 and U+2014 to a hyphen in the generated Python files. The generators' progress output is kept in a temporary log and shown only on failure. The default hooks would read a ruff configuration around the output (the project excludes the directory, so ruff would skip it) and write a cache into the output directory. The client template of 0.29.1 contains em dashes, which the text-rules hook rejects. The raw TypeScript output already passes every hygiene hook (861 KiB, below repo.max_file_kb).
M2-23/6 2026-10-12 One shared fixture set in sdk/testdata/tolerance/ for both SDKs; tools/ci/components.py gives sdk-python and sdk-typescript the prefix sdk/testdata/. Both SDKs prove the same cases (06 15 G5); a fixture change runs both SDK gates in CI. sonar-project.properties already excludes **/testdata/**.
M2-23/7 2026-10-12 Gate scope of generated code: excluded from ruff (extend-exclude), Prettier (.prettierignore), ESLint (ignores), bandit (-x */generated/*), coverage (pytest-cov omit, Vitest exclude) and Sonar; included in the drift check, compileall, mypy, tsc, the import test, the pre-commit hygiene hooks, gitleaks, the deny-list scan, the license and OSV gates and CodeQL. Two narrow mypy overrides cover defects of the 0.29.1 output that are correct at run time: arg-type in bothy_sdk.generated.client (__exit__(*args: object)) and assignment, attr-defined, return-value in bothy_sdk.generated.models.upload_state (a member named like the model). ruff D with the google convention is active for the hand-written SDK code (tests excluded). Like *.gen.go in M1-17: the generator and G4 hold the generated code; docstring, style and coverage rules would only measure the generator's templates. The overrides name the module and the error codes, so any new type error elsewhere still fails.
M2-23/8 2026-10-12 make api-clients iterates API_CLIENTS (sdk/python:sdk-python sdk/typescript:sdk-typescript packages/api_client:apps): no tracked manifest gives SKIP, an unselected component gives SKIP, a tracked manifest without a Makefile defining generate-check fails naming the directory and the contract, otherwise make -C <dir> generate-check runs and its failure fails the gate. bootstrap-ci runs npm ci --prefix sdk/typescript --ignore-scripts and uv sync --project sdk/python --locked when the locks are tracked. make typecheck runs mypy inside each Python project; make lint passes bandit -x <p>/tests,<p>/.venv,*/generated/*. gates.md "Skipping and failing" and D-QA-22: the old recipe skipped a client without the target silently. mypy found neither configuration nor target from the repository root, and bandit descended into the project's .venv. CI runs bootstrap-ci in the jobs gates and sonar, so no workflow changes.
M2-23/9 2026-10-12 New named values quality.coverage.python_min_percent and quality.coverage.typescript_min_percent in 23 11, each 80 like quality.coverage.go_min_percent; held by fail_under in sdk/python/pyproject.toml and by all four coverage.thresholds in sdk/typescript/vitest.config.ts, checked by test_config_values.py. 23 3 asks for the threshold in each project's own configuration, and thresholds exist only as named values (23 1 point 5). Both runners were shown to fail a run below the threshold.
M2-23/10 2026-10-12 Runtimes and dependencies: requires-python = ">=3.11", engines.node = ">=24". Runtime dependencies are exactly what the code imports: Python attrs>=26.1.0 and httpx>=0.28.1,<0.29, TypeScript openapi-fetch ^0.17.0; exact versions in the locks. ruff, mypy, pytest, pytest-cov and PyYAML use the versions of tools/pyproject.toml. On 2026-10-12 python.org lists 3.10 as the oldest maintained line, but the generated core imports enum.StrEnum and parses ...Z times with datetime.fromisoformat, both 3.11; Node.js 24 is the only line in active LTS that day. Compatible ranges are what a library declares; one rule set for all Python of the repository.
M2-23/11 2026-10-12 TypeScript tool chain: TypeScript 5.9.3 with strict, Prettier 3.9.9 (Markdown excluded), ESLint 9.39.5 with typescript-eslint 8.56.0 (recommendedTypeChecked), Vitest 5.0.3 with @vitest/coverage-v8 writing coverage/lcov.info; all exact pins. sonar-project.properties excludes vitest.config.ts and eslint.config.mjs from coverage. openapi-typescript 7.13.0 needs TypeScript 5 (TypeScript 7 has no compiler API). ESLint 10 and typescript-eslint from 8.56.1 depend on minimatch 10.1 or later, licensed BlueOak-1.0.0, which is not on the allowlist; the allowlist is not extended. Pins are at least the Renovate cooldown old. Markdown keeps the compact table style of the repository. The two configuration files are code for Sonar that no test can execute.
M2-23/12 2026-10-12 The TypeScript runtime client is openapi-fetch 0.17.0. src/tolerance.ts exports KNOWN_EVENT_TYPES (checked both ways against components["schemas"]["Event"]["type"] by satisfies and a never check), decodeEvent, isKnownEvent, knownEvents, UnknownEvent, KnownEvent and toleranceMiddleware (drops null members of application/json and every application/*+json body, such as Problem Details and the JWKS, keeps null array items, passes other media types, empty bodies and malformed JSON unchanged); createApiClient always registers it. 11 11.1 left the runtime client to this ticket; 06 14 probed openapi-typescript with openapi-fetch. TypeScript has no runtime validation (06 13 point 3), so only the event type and null need code. isKnownEvent is an addition to the brief's API, the type guard knownEvents is built on.
M2-23/13 2026-10-12 sdk/python/osv-scanner.toml holds PackageOverrides for colorama 0.4.6 (BSD-3-Clause), jinja2 3.1.6 (BSD-3-Clause) and shellingham 1.5.4 (ISC), each pinned to its version with the reason. OSV-Scanner reports their licenses as non-standard; the LICENSE files of these releases were read (licenses.md "Lizenz-Gate" point 3). All three come only with the generator.
M2-23/14 2026-10-12 Branch feat/M2-23-generated-clients from int/m1-wave5 (46d20a0) in its own worktree; this session does not merge it locally and does not remove the worktree, the PR lane ports it. Text added to German chapters and pages is English, and the SDK READMEs are rewritten in English. Ruling rows use the code's format M2-23/<n> instead of the brief's D-M2-23-<n>. tools/ci/tests/test_product_name_places.py (M1-46) does not exist on the base, so only 02 6 lists the new kind. The launcher's instructions override the brief on branch, merge and language. The brief asks to follow merged code where names differ.
M2-23/15 2026-10-12 The generic deny-list rule for macOS home directories matches /Users/ with its case ((?-i:/[U]sers/)); the rest of the rule and every other rule still ignore case. test_generic_rules_allow_documentation_values holds two lower-case API paths as allowed, test_generic_rules_catch_leaks keeps a home path with the upper-case segment as a finding. Red team M2-23, Major 3: the generated Python core has the modules api/users/*.py (tag users of openapi.yaml), and the case-insensitive rule made make denylist fail. A lower-case /users/ is an API path, never a macOS home directory. Generated code stays inside the deny-list scan (M2-23/7).

M2-32 Recording Hermes Agent before the build

No Date Ruling Reason
D-M2-32-1 2026-10-12 The live recording is part of the component's default test run: make test and every make check that selects the gate component plugins repeat it; without network it fails, it never skips. A Hermes surface is public when the pinned version documents it for platform plugins (ADDING_A_PLATFORM.md, the developer and user guide) or when it is a name without leading underscore on BasePlatformAdapter, on the plugin context of register(ctx) or on PlatformConfig; an underscore method the pinned base class documents as an override point is public for overriding only; everything else is internal. 09 17.3 asks for a recording test, RK-12 needs the check continuously; a recording of the build day alone would be a stored value whose rule nobody checks again (R-QA-02).
D-M2-32-2 2026-10-12 plugins/hermes-adapter is a Python project with the harness under tests/hermes_harness (typed strict, measured, documented like product code); the probe plugin is outside mypy and coverage. The base had no M2-23, so this ticket fixed exactly the two Makefile defects (mypy runs inside each project; bandit excludes <project>/.venv and <project>/generated besides tests) and added quality.coverage.python_min_percent (23 11, test_config_values.py checks every tracked project under sdk/ and plugins/, gates.md row coverage). A parallel M2-23 resolves to one implementation at the merge. The harness is test support the component never ships; the probe only runs inside the Hermes process, where the recording proves it.
D-M2-32-3 2026-10-12 Pin rule: the newest release tag of the public repository older than deps.min_release_age on the recording day, resolved with git ls-remote (here tests/hermes_pin.toml). renovate.json has a regex manager for the file (github-tags, currentValue from tag, currentDigest from commit) and a package rule whose note asks to record again, review the fixture diff and update 09 17.10; the recording test fails until then, on purpose. Same cooldown as every dependency (ADR 0041); the failing test is the reminder, like the rule "release binaries".
D-M2-32-4 2026-10-12 Install from a git archive export, never from the checkout; the checkout is read-only. The pinned commit has a uv.lock, so uv sync --locked --no-dev installs from it (no hermes_requirements.txt). The pinned version refuses wheel builds outside its Nix build (setup.py guard), so the export is installed editable; the installed digest covers every RECORD of the venv and the source tree an editable install runs from. Build backends write into the source tree; the editable target is the throwaway export, so the checkout stays untouched and a change of the running code is still found.
D-M2-32-5 2026-10-12 Isolation set: throwaway homes, an allowlisted environment, a null keyring, refusing shims for security, secret-tool, launchctl and systemctl that record only tool and subcommand, the refusing egress proxy, gateway run --force (no service probe of the real account), and on macOS sandbox-exec denying outbound network except the loopback ports of the harness' own servers (fake model, control channel, egress recorder) and the root's own Unix sockets, signals and process information outside the sandbox, file contents and writes below the real home except the root, and the execution of launchctl and security. The throwaway configuration disables lazy installs, tirith, the update check, the model catalogue refresh, tool search, and points models_dev.url at the fake endpoint. The sandbox does not block the macOS keychain API; the keychain stays out of reach through the throwaway HOME (no default keychain), the security shim and the exec denial of its binary. The fake endpoint judges every credential header: every value of every credential header and credential query parameter counts, and a foreign value in any of them or two different credentials in one request is unexpected and fails the run; which header carries a fake credential is not judged, because the pinned model client sends every credential, the OAuth token included, as x-api-key to an endpoint outside anthropic.com (_auth_style), while its context length probe sends the OAuth token as a bearer token. Each part closes one way to a real credential, a real model, a local model or gateway, a service or another Hermes installation; on Linux the unreachable credentials are the guarantee.
D-M2-32-6 2026-10-12 The probe's platform name is recording_probe; it is a directory plugin in <HERMES_HOME>/plugins/recording_probe, enabled through plugins.enabled. The pinned loader imports a directory plugin through its __init__.py, so the probe has plugin.yaml, a two-line __init__.py (no Hermes import) and probe.py. The product name may appear only at the places of 02 6; the adapter's platform key belongs to M2-33.
D-M2-32-7 2026-10-12 Fixtures hold no third-party prose (Hermes texts only as sha256:<hex>:<length>). The pattern covers the adapter methods only (no hooks, attribute reads or the timer calls send_typing/stop_typing), renumbers IDs within itself, collapses runs of send_draft and of non-final edits of one streamed message, keeps only the last set_status_text call as the last entry (Hermes calls it from the agent thread, so its position among the event loop's calls depends on thread scheduling), drops no-op edits and compares everything else exactly. inputs_sha256 covers the pin, the probe, the scenarios and the harness modules; a change without recording again fails the offline tests. Repeat counts and hook timing depend on chunk timing and threads, not on Hermes' contract; foreign text could carry machine details.
D-M2-32-8 2026-10-12 The harness deadlines are named constants in tests/hermes_harness/limits.py, no configuration keys. They bound a test, not product behaviour (precedent of M1-73's probe).
D-M2-32-9 2026-10-12 Scenarios beyond Design note 9, each as evidence for an adaptation: tool_without_progress, busy_steer_global, busy_queue_global, busy_steer_command, interrupt_command, credential_login_file_optout, session_resume_same_thread, session_resume_admin. The harness waits for the end of the gateway's startup restore before the first step (the probe asks the internal flag gateway_runner._startup_restore_in_progress; it decides timing only, never a fact) and for named calls instead of quiet periods where a step has a definite end. An adaptation needs its own evidence; inbound during the startup restore is queued and replayed later, which made patterns depend on timing.
D-M2-32-10 2026-10-12 Adaptation progress_hooks: 17.3 states the observed patterns; tool steps come from set_status_text (text verbatim as title, closed at None, the next status, draft or answer, or on_processing_complete); without drafts the buffered edit_message content is the source of /last_text_excerpt; format_tool_event and render_message_event keep their rows with "not called in v2026.9.24"; the installer sets display.platforms.<platform_key>.tool_progress: off. Measured: no caller of format_tool_event in the pinned gateway; progress bubbles arrive as send unless tool_progress is off.
D-M2-32-11 2026-10-12 Adaptation steer: 17.5 names the global key display.busy_input_mode (one Hermes profile is one agent principal); steer stays a supported mode, /steer <text> is recorded as a further way. The report tells M2-33 that AG-13 covers steer and queue through the global key. Measured: the per-platform key is never read; the global steer and queue behave as 09 7 expects.
D-M2-32-12 2026-10-12 Adaptation credential_type: 17.8 names hermes_cli.runtime_provider.resolve_runtime_provider() with agent.anthropic_credentials.anthropic_route_is_oauth(...) as the source of the compatibility layer with a contract test against the pinned version (M2-33); RK-13 and T-AI-08 keep "non-public surface". 10 10.1 gets the precedence and the rule that in organization the profile's home holds no Claude Code login file and sets auth.adopt_external_logins: false; "per turn" stays. Measured: no public surface; the internal answer equals the ground truth in all eight profiles; the login file is used as OAuth, the opt-out works, a .env change applies at the next turn.
D-M2-32-13 2026-10-12 session_switch holds, and the ways go into 17.3, 17.4 and 9.5 point 2: /new through handle_message answered via tools.slash_confirm.resolve(..., "once"); /resume <id> --all sent for the adapter's admin sender listed in PlatformConfig.extra.group_allow_admin_from (a plain /resume binds only a session of the same thread); the session ID comes from the hook pre_llm_call. Measured: plain /resume of another thread is blocked, the admin form binds it; the hook is a documented plugin surface.
D-M2-32-14 2026-10-12 docs/RISKS.md was not on the base branch of this ticket, so RK-12 and RK-13 got their addition when the ticket was ported onto main, where the file exists: the column "Mitigation" of RK-12 adds "Recording against the pinned version (plugins/hermes-adapter/tests/hermes_pin.toml) in every test run; it found: no call of format_tool_event, busy_input_mode only global, session ID through the hook pre_llm_call", that of RK-13 adds "Recording in every test run; no public surface, Hermes' internal resolution is right in all profiles; the login file of Claude Code is used, switched off with auth.adopt_external_logins: false". Ratings unchanged. T-AI-08 stays unchanged (no public surface). Creating the file in the ticket would have collided with the plan port that brought it.
D-M2-32-15 2026-10-12 The task order of this run wins over the brief's Boundaries: the work stays on feat/M2-32-hermes-recording from int/m1-wave5; no local merge and no git worktree remove, the PR lane ports it. The launcher's task text names the branch and leaves integration to the PR lane.
D-M2-32-16 2026-10-12 The temporary root of the recording is made with mktemp -d in TMPDIR, or in /tmp when that path is too long for the Unix sockets of the Hermes homes (sun_path; the default TMPDIR of macOS is). The pinned gateway binds sockets below its home and falls back to the system temp directory otherwise, outside the root.
D-M2-32-17 2026-10-12 Every text turn of a scenario is paced (the fake model pauses CHUNK_PAUSE_S after every chunk, the last one included), and a scenario with tool calls outside progress_hooks sets display.platforms.recording_probe.tool_progress: off; timing_problems in tests/hermes_harness/scenarios.py names each violation and test_scenarios_do_not_depend_on_stream_timing holds every checked-in scenario to it. The pinned stream consumer drafts only the deltas it drains before the stream ends, and its progress task polls on its own clock; unpaced answers and progress bubbles made the patterns of nine scenarios flaky under load.
D-M2-32-18 2026-10-12 plugins/hermes-adapter/osv-scanner.toml overrides the license of colorama 0.4.6 (Windows-only dependency of pytest in the dev group) to BSD-3-Clause after checking its LICENSE.txt; quality/licenses/allowed.txt is unchanged. test_licenses_pass_the_allowlist_to_osv_scanner leaves the Python and TypeScript projects out with PY_PROJECTS= and TS_PROJECTS=, exactly as M2-23 does, because the first tracked project under plugins/ would join the lock list of that server test. OSV-Scanner reports the PyPI metadata 'BSD License' as non-standard; docs/quality/licenses.md 'Lizenz-Gate' item 3 prescribes a per-package override with reason; the same test line as M2-23 merges without conflict.
D-M2-32-19 2026-10-13 Port onto main: chapter 09 and 10 changes are applied to docs/spec/09-agents.md and docs/spec/10-backend-policy.md in English; the tests that read 17.10 match the English words (Item, confirmed, adapted, none, to be checked). The comments of tests/hermes_pin.toml and of the harness modules keep the name 09-agenten.md, because those files feed inputs_sha256 and a changed byte would need a new recording; they change with the next recording. Translation and conflict resolution only; the recorded evidence stays valid.
D-M2-32-20 2026-10-14 plugins/hermes-adapter/pyproject.toml switches off warn_unreachable of mypy for the module hermes_harness.gateway only. The module branches on sys.platform (Linux reads /proc, macOS asks lsof), so mypy on Linux marks the macOS branch unreachable and the reverse; the module itself feeds inputs_sha256 and cannot change without a new recording (D-M2-32-19).

M2-38 Host service module and Noise core

No Date Ruling Reason
D-M2-38-1 2026-10-12 The module path is github.com/OWNER/bothy/services/host (P-05, the placeholder OWNER is replaced mechanically by V1-03). Its go and toolchain lines equal those of services/server/go.mod (go 1.26.0, toolchain go1.27.2); test_go_directives_agree holds every tracked module equal. Dependencies: github.com/flynn/noise v1.1.0 (the newest release) with golang.org/x/crypto and golang.org/x/sys raised to their newest release; tests use the standard library only. CI installs one Go and ADR 0005 names one line; OSV-Scanner reads go.mod and reports every advisory of a listed version, reachable or not.
D-M2-38-2 2026-10-12 services/host/.go-arch-lint.yml is maintained by hand with one component per package (cmd, noise, frame, fingerprint) and the only vendor github.com/flynn/noise for noise; the host module has no module register. Recorded in 02 "Code layout" and in the row "Go-Modul" of 23 3. The host is one small binary without the server's modules, ports and tables; a register format for it would be a second generator without a second user.
D-M2-38-3 2026-10-12 make perf runs only for PERF_MODULES := services/server (filtered by the selected GO_MODS); with none selected it prints SKIP perf no Go module with performance targets selected before the prerequisite check. A listed module without perf/ still fails as M1-01 built it. The fakes of tools/ci/tests/test_make_server_gates.py pass PERF_MODULES with their throwaway module. 24 names no performance target for the host service, and M1-01 failed make perf for every Go module without perf/.
D-M2-38-4 2026-10-12 make test fails for a Dart package without test/ (<package>: no test/ directory (docs/spec/23-quality.md, 3)), and make coverage fails for one without coverage/lcov.info (<package>: coverage/lcov.info is missing; make test writes it); both loops stop at the first failing package. 23 1 point 3 and R-QA-02: before, a package with code but without tests passed silently, and a missing report skipped the threshold. bothy_noise is the first tracked Dart package, so these lines ran for the first time.
D-M2-38-5 2026-10-12 GATE_COMPONENTS["apps"] of tools/ci/components.py gains the prefix services/host/testdata/noise/; a change there selects apps and server. The Dart tests read the vectors, recordings and fingerprint vectors there; without the prefix a new Go recording would not run the Dart replay in CI.
D-M2-38-6 2026-10-12 cmd/bothy-host exists as a dispatcher without commands (usage, exit codes 0 and 2, like cmd/bothy of M1-05), so make -C services/host release-build VERSION=<x.y.z> OUT=<dir> can build bothy-host_<version>_<os>_<arch>[.exe] for the five platforms of 18 "Components". The binaries are unsigned and built with CGO_ENABLED=0 -trimpath; M7-23 adds signature, SBOM, checksums and installation packages and may change the layout; M2-45 may switch on cgo for a platform with a recorded reason. CodeQL builds the module with CGO_ENABLED=0 as well. 23 3 asks for release-build, which needs a main package; M2-42 registers the commands.
D-M2-38-7 2026-10-12 The session and host IDs enter the prologue as the 16 bytes in the octet order of RFC 9562 (as the text form reads left to right). ParseID and uuidToBytes accept only the canonical 36-character form 8-4-4-4-12 with hex digits in either case; no version check. Recorded in 24 5.2. 24 5.2 names 16 bytes but no order; the prologue binds bytes, and the server validates its own IDs.
D-M2-38-8 2026-10-12 After the first failed Decrypt or any failed handshake step the object refuses every further call (ErrBroken, NoiseError.broken); a received message over 65535 bytes or shorter than the tag counts as a failed decryption. A local payload or plaintext over the limit gives ErrMessageTooLong without using a nonce and without breaking. Each direction keeps its own counter and refuses at 2^64 - 1 (ErrNonceExhausted); a call out of order gives ErrState. The limits are checked by the wrapper before the library is called. Recorded in 24 5.2. A failed authentication means manipulation (24 5.3, 4104); continuing would let a relay probe. Noise revision 34 section 5.1 reserves the last nonce. Own checks keep the behaviour independent of the library version.
D-M2-38-9 2026-10-12 A Diffie-Hellman result of 32 zero bytes fails the handshake in Go and Dart. golang.org/x/crypto/curve25519 (under DH25519 of flynn/noise v1.1.0) already rejects it; the Go side still wraps DH25519 in a DHFunc that checks the result in constant time, the Dart side checks the result of X25519 of cryptography, which does not reject it in the Dart VM, and maps every other failure of the curve function to handshakeFailed (in the browser BrowserX25519 throws a StateError for a low-order key; red team M2-38, finding 1). Every failure of a Dart handshake step breaks the object, whatever its type. Recorded in 24 5.2. Both sides must behave the same, and an all-zero shared secret means a key that an attacker chose; the wrapper keeps the rule independent of the library version.
D-M2-38-10 2026-10-12 Frames: all integers big-endian; start and resize carry exactly 4 bytes, columns then rows, each 1 to 65535; data carries 1 to 65518 bytes; close, replay_begin and replay_end carry no body; exit carries exactly 4 bytes, a signed 32-bit two's complement integer; a frame has at most 65519 bytes. start, resize, close only from the device, exit, replay_begin, replay_end only from the host, data both ways. Errors: empty, too long, unknown type, wrong direction, malformed; every decode error ends the session like an unknown type (4104), M2-45 does the mapping. IsInput is true exactly for data and resize. Recorded in 24 5.4. 24 5.4 names field widths but no byte order or body rules; an empty data would count as input without input (24 6.3).
D-M2-38-11 2026-10-12 Fingerprint: Crockford base32 with the alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, 5-bit groups from the most significant bit, the last group padded with zero bits, upper case, no padding and no check symbol; both keys raw and 32 bytes (the Ed25519 key as the decoded x of the JWK); display form 52 characters in 13 groups of four joined by -; Compact removes the separators. testdata/noise/fingerprint.json holds the known vector and eight derived ones with the field names noise_public, ed25519_public, fingerprint. Recorded in 24 4.3. 24 4.3 names the formula and the grouping only; server, host service and apps must compute the same string, and the field names avoid the keywords of the gitleaks rule generic-api-key.
D-M2-38-12 2026-10-12 Until apps/client has its own pubspec.yaml, environment.flutter of packages/bothy_noise/pubspec.yaml (3.47.7, the newest stable release on 2026-10-12, Dart ^3.13.5) is the Flutter pin CI reads (flutter-version-file); test_flutter_pins_agree holds every pin equal and exact. The tests use package:test and run with flutter test --coverage, which accepts it. The root Makefile runs the Dart gates with flutter, and CI must install exactly one known version; the package still imports no Flutter (15 3.1 rule 7).
D-M2-38-13 2026-10-12 The base branch has no pub workspace (no root pubspec.yaml), so packages/bothy_noise/pubspec.lock is tracked; M4-01 moves the package into the workspace (resolution: workspace) and drops this lock file. OSV-Scanner and the license gate read the lock file.
D-M2-38-14 2026-10-12 The responder NkResponder lives in the separate library package:bothy_noise/testing.dart; package:bothy_noise/bothy_noise.dart does not export it. 15 23.1 allows the responder only for tests, while M4-35 can use it as a fake host.
D-M2-38-15 2026-10-12 The vector files vectors/cacophony.txt (repository haskell-cryptography/cacophony, the former centromere/cacophony, commit 8ee9d41e34a1a596cfa3ab12aa4069ff87dc1247, Unlicense) and tests/vector/noise-c-basic.txt (rweather/noise-c, commit cfe25410979a87391bb9ac8d4d4bef64e9f268c6, MIT, license file COPYING) are stored unchanged but gzip-compressed (gzip -n -9) under services/host/testdata/noise/vectors/, with their license files and a README table of source, commit, license and SHA-256 of the content that TestVectorProvenance holds. Accepted for test data: a license of quality/licenses/allowed.txt or a public-domain dedication such as Unlicense. Compression keeps the upstream content byte for byte below repo.max_file_kb, keeps the hygiene hooks from rewriting it and keeps the published test keys out of the secret scanners' text rules; test data is never shipped.
D-M2-38-16 2026-10-12 The ticket is built on feat/M2-38-noise-core from int/m1-wave5 (46d20a0) in its own worktree; it is not merged locally and the worktree stays, the PR lane ports it. The rulings of this ticket stand in this section in the table format of the base (the brief names a "section 10" with rows D-M2-38-<n>, which the base does not have); docs/RISKS.md does not exist on the base, so the facts for RK-28 (pinned versions, release dates, advisories, date of the check) stand in 24 5.2 and in the ticket report instead. The launcher's task takes precedence over the brief's merge step; the brief says to follow the merged code where it differs.
D-M2-38-17 2026-10-12 Two guards of M1-01 change outside the brief's file list: tools/ci/tests/test_make_server_gates.py passes PERF_MODULES with its throwaway module (D-M2-38-3) and passes DART_PKGS= in test_licenses_pass_the_allowlist_to_osv_scanner, whose expected arguments listed only the Go lock file. Both tests encoded the absence of a module without performance targets and of a tracked Dart package; the recipes under test are unchanged.
D-M2-38-18 2026-10-12 services/host/.golangci.yml takes the linters and settings of the server's configuration with the host import prefix; it drops the exclusion rules for server paths and keeps two of its own: forbidigo is off for cmd/bothy-host/ (the one place that reads the environment) and for the two tests that read UPDATE_RECORDINGS, the switch of make -C services/host recordings. A verbatim copy would name paths that do not exist in the host module and would forbid the switch that regenerates the test data.
D-M2-38-19 2026-10-12 flynn/noise v1.1.0 draws the ephemeral key of a message from Config.Random (Config.EphemeralKeypair serves pre-messages only), so a fixed ephemeral key, which the device needs because it reports cek before the handshake, is passed as a reader that yields exactly its 32 private bytes. HandshakeHash comes from ChannelBinding, the initiator ephemeral key from PeerEphemeral. The brief allows a deterministic Config.Random reader when the pinned version has no fixed ephemeral key for messages.
D-M2-38-20 2026-10-12 packages/bothy_noise/osv-scanner.toml overrides the license of each package of pubspec.lock (package and version, one entry each) with the license of its own LICENSE file: Apache-2.0 for cryptography, MIT for yaml, MIT and BSD-3-Clause for node_preamble (its LICENSE holds both texts), BSD-3-Clause for the others. deps.dev has no license data for pub packages, so OSV-Scanner reports every one as UNKNOWN and make licenses fails; docs/quality/licenses.md item 3 asks for a checked entry with reason per package, never a blanket override.
D-M2-38-21 2026-10-12 The Dart lines of make lint and make test run flutter pub get --enforce-lockfile in each package first; a pubspec.lock that no longer matches pubspec.yaml fails. Every public List<int> input of bothy_noise rejects a value outside 0 to 255 (lib/src/bytes.dart): a received message or frame fails like any other bad input of its step (handshakeFailed, decryptFailed, malformed) and breaks the object, a bad key or ID throws invalidKey or invalidId, and a bad local payload, plaintext or prologue throws an ArgumentError and changes no state; and an overlapping decrypt that resolves after a failed one throws broken. Red team M2-38 (findings 2, 4, 5): flutter analyze and flutter test rewrote a stale lock file silently, so make osv and make licenses alone could check another lock than the tests ran with; Uint8List.fromList truncated such values silently where Go cannot receive them; the broken rule must hold for callers that do not await each call.
D-M2-38-22 2026-10-14 The root Makefile runs Flutter as $(FLUTTER), flutter without the variables GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_COMMON_DIR and GIT_PREFIX, in make lint and make test; test_flutter_runs_without_the_git_variables_of_a_hook holds it. git exports these variables to hooks, and the pre-push hook runs make check; Flutter reads its own version with git inside the SDK, so under a hook it read this repository, called its version unknown and refused the pinned SDK version of pubspec.yaml. The same rule already covers make api-clients.

M1-16 Event log with commit-ordered sequence

No Date Ruling Reason
M1-16/1 2026-10-12 Branch feat/M1-16-event-log in its own worktree from the head of int/m1-wave5 (46d20a0); this session does not merge it locally and does not remove the worktree, the PR lane ports it. The launcher's task overrides the merge step of the brief ("Definition of done").
M1-16/2 2026-10-12 A payload must be smaller than sync.event_payload_max_bytes (a payload of exactly the limit is rejected), as 04 "Rules for the payload" item 2 ("smaller than") and the fixed requirement 16 of the brief ("below") say; 03 ("up to") and the design note ("at most") read as an upper bound the strict rule also keeps. The core contract chapters disagree by one byte; the strict reading satisfies every text.
M1-16/3 2026-10-12 The appender also rejects a payload whose strings or keys contain U+0000. jsonb cannot store U+0000 (SQLSTATE 22P05, untranslatable_character; JSON types and PostgreSQL error codes, retrieved 2026-10-13); the producer would otherwise get a database error inside the hook instead of a kind internal error at the append.
M1-16/4 2026-10-12 Event.Envelope and the mapping rules live in the port internal/sync; domain/ holds the validation, the partition arithmetic and the gap check. The brief asks for the method on the port type and for the rules in domain; the port may import only kernel packages (.go-arch-lint.yml), so it cannot call domain.
M1-16/5 2026-10-12 DeliveryFields carries SyncEpoch as well (sync_epoch, webhooks only, ADR 0059). EventEnvelope in openapi.yaml has the member, and TestEnvelope_FieldsMatchOpenAPI requires the envelope members to equal its properties.
M1-16/6 2026-10-12 The dedicated connection is (*pg.Pool).DialDedicated(ctx, applicationName) instead of a free function pg.DialDedicated. A connection with the configuration and password source of the pool needs both; the pool already holds them.
M1-16/7 2026-10-12 wire.MigrateUpSteps(pool, fsys, MigrateUpConfig{JobsSchema, Sync}, result) adds the after-step event_log_partitions behind the instance step; wire.MigrationSteps, which pgtest.WithSteps uses for templates, keeps only the instance step, so test databases have no partitions and the event log tests create theirs with their own size. bothy migrate up must use the configured values (brief scope item 9); pgtest's step function gets no configuration.
M1-16/8 2026-10-12 The new partition table is LIKE event_log INCLUDING DEFAULTS INCLUDING CONSTRAINTS. ATTACH PARTITION requires the CHECK constraints of the parent on the attached table (ALTER TABLE, retrieved 2026-10-13); with INCLUDING DEFAULTS alone it fails.
M1-16/9 2026-10-12 sync_event_log_ensure_partitions locks event_log in SHARE UPDATE EXCLUSIVE mode before it reads the bounds; sync_event_log_drop_expired stops at the first partition it keeps, so only a prefix of partitions is ever dropped. Two concurrent runs (job and bothy migrate up) would otherwise create the same table twice; occurred_at is the start of a transaction and need not grow with seq (now() returns the transaction start, Date/Time functions, retrieved 2026-10-13), and a hole in the retained range would break min_retained_seq (04 "resync_required").
M1-16/10 2026-10-12 The rows go in with one multi-row insert from arrays, and pg_notify runs in the same statement (a data-modifying CTE), not with sqlc :copyfrom and not as a second statement. Every round trip under the counter lock lowers the commit rate (perf.event_commit_min_per_s); one statement is one round trip, COPY would need a second one for the notification.
M1-16/11 2026-10-12 Without a valid span context the correlation ID of the request in kernel/reqmeta (if it is a UUID) becomes correlation_id; trace_context stays empty. Without a span and outside a request both are NULL. 03 event_log.correlation_id: a request without traceparent gets an ID from the server; platform/httpserver keeps that ID in reqmeta, the same the response and the audit entry carry.
M1-16/12 2026-10-12 The error line of an insert without a partition carries bothy.event.seq (the first seq) and error.type (23514); the whole range is in the error text. The log handlers keep only catalogue attributes (platform/otel/attr), and the catalogue has no second seq attribute; adding one is a telemetry change outside this ticket.
M1-16/13 2026-10-12 Bounds of the new keys: sync.event_log_partition_size at least 1, sync.event_retention at least 1s, sync.event_payload_max_bytes 3 to 1048576, sync.log_poll_interval at least 1ms, sync.tail_batch_size 1 to 2147483647 (the PostgreSQL integer maximum, Numeric types, retrieved 2026-10-13), sync.partition_maintenance_interval at least 1s, sync.event_log_partitions_ahead 1 to 1000. The chapters give only defaults; {} is the smallest payload, 0 partitions ahead would let the next commit miss a partition, and a payload limit above http.max_json_body_bytes would never be reached by a request.
M1-16/14 2026-10-12 The tail's start step waits until Run has read the head (Tail.Started) before the public listener opens; a tail that ends with an error calls Runner.Fail, and its stop step event_tail runs in PhaseEventTail without its own budget. 02 "Start and graceful shutdown" step 8 sets local_head before the listener; the stop only cancels a loop.
M1-16/15 2026-10-12 ADR 0009 is not changed; its confirmation is held by TestEventLog_ConcurrentWritersGapless and TestTail_ListenConnectionLossLosesNothing. The brief extends only ADR 0052, 0053 and 0063.
M1-16/16 2026-10-12 make perf measured perf.event_commit_min_per_s below the threshold on a development machine whose database runs in a VM (one round trip about 0.4ms from the test process); the gate stays as ADR 0063 defines it and the numbers are in the report, not in docs. Under the counter lock every unit spends about three round trips and the commit, so the rate there is bound by the VM network, not by the pattern; the reference profile runs PostgreSQL locally (02 "Configuration values of this chapter", perf.reference_profile).
M1-16/17 2026-10-12 The module README of sync keeps its generated sections (purpose, port, dependencies, tables, actions, events, jobs), which make register-check compares with the register, and adds the sections of this ticket. The generated sections are not this ticket's text.
M1-16/18 2026-10-12 The appender rejects numbers with an exponent, invalid UTF-8 and unpaired surrogate escapes, and the reads compact the jsonb text before they hand payloads to the sinks. Red team round 1 (M1): jsonb stores numbers as numeric and writes 1e300 out in full (JSON types, retrieved 2026-10-13), so a checked payload could be stored and delivered far above sync.event_payload_max_bytes; without exponents the compact form of the normalised jsonb is never larger than the checked bytes.
M1-16/19 2026-10-12 The partition maintenance runs ensure and drop_expired in two units of work; an error of the drop is ErrDropDeferred with the result of the first unit, the job is retried by River, and the partition step of bothy migrate up ignores it. Red team round 1 (M2): a DETACH waiting behind a long reader rolled back the partitions created in the same unit, and appends ran out of partitions.
M1-16/20 2026-10-12 sync_event_log_drop_expired clamps p_head to max(seq) and p_cutoff to now(); sync_event_log_ensure_partitions refuses a size above 10^12 and more than 1000 partitions ahead, and sync.event_log_partition_size has the maximum 10^12. Red team round 1 (Minor, hardening): bothy_app could drop the head partition with arguments of its choice, and an unbounded size overflows bigint in p_head + p_ahead * p_size.
M1-16/21 2026-10-12 A sink error while the context of Tail.Run has ended is the orderly end of Run (nil), not a fatal error; a stored payload null renders as {} in the envelope. Red team round 1 (Minor): a sink that honours the context would otherwise turn every shutdown into exit code 1.
M1-16/22 2026-10-12 Numbers with more than 131072 digits before or 16383 digits after the point (the numeric limits, Numeric types, retrieved 2026-10-13) are rejected at the append; only a lock timeout of the drop unit becomes ErrDropDeferred, any other drop error is returned unchanged. Red team round 2 (M1-rest): such numbers passed the appender and failed the unit at the insert with SQLSTATE 22003 (numeric_value_out_of_range); a permanent drop failure must not pass silently as deferred.
M1-16/23 2026-10-12 Long digit runs (17 to 20 digits) never appear as literals in tests: names and limits are built (fmt.Sprintf("%019d", ...), math.MaxInt64 as a parameter), and the generic white-label scan (tools/denylist, make test and make denylist) runs on the working tree before every commit of this ticket. Red team rounds 1 and 2 (B1): the generic rule for account and card numbers matched partition names and the bigint maximum twice; the gate that catches the class exists, the gap was running it before the commit.
M1-16/24 2026-10-12 Port onto main, after M1-28: the start step of the event log is 6c (sync), after 6b (authz, M1-28); both come from the same base, and neither depends on the other. The load test report (perf/internal/report) reads the English section "Configuration values of this chapter" of 02 and the place Load test; the build notes go into the English ADRs 0052, 0053 and 0063; references name the English chapter files and headings of main. Translation and conflict resolution only; the behaviour equals the approved ticket.