| D-W-01 |
The resumption checked every item of the aborted pass against the state in the working tree (done, partial, open, to be rejected, with the location of the evidence) and completed the items that were not complete. No item counts as done just because a file was touched. |
An aborted writer leaves changes without proof; only the check per item proves the state. |
Commits from "docs(kern): core contract extended after gaps part 3" to "docs(briefs): PLAN and M1 briefs aligned after gaps part 3" |
| D-W-02 |
Decisions of the first two passes are not reverted. |
The briefs build on them; reverting them would be a new decision without a new reason. |
Section 8 |
| D-W-03 |
The owner addendum (terminal, visualization, showcase) was added in the same resumption: design with the rulings T1 to T11, then core contract, chapters 24 and 25, ADRs, plan and briefs. |
The planning state before the resumption contained neither; the owner requirement is binding. |
Section 7, PLAN 2 |
| D-W-04 |
One writer in the main tree at a time; reviewers only read; every finding of a reviewer is fixed and seen by the next fresh reviewer. |
No overlapping changes; no finding counts as fixed without a fresh look. |
PLAN 4 |
| D-W-05 |
The closing documents (SPEC, DECISIONS, OPEN-QUESTIONS, RISKS, README, navigation, CHANGELOG) are written last; until then their absence is the only expected warning in the strict docs build. |
They summarize the state and can only be correct afterwards. |
PLAN M0-04, D-G-347 |
| D-W-06 |
Commit identity: the owner chose the identity of the commits; it counts as a public identity in the sense of public-switch.md step 2. The private deny list allows it only in the author and committer fields; every other occurrence of the same domain stays forbidden. Rewriting the authors is dropped. Before the first push, only the rewrite with git filter-repo --replace-text for the old sample values of the gitleaks exception remains; afterwards the exception is deleted (M0-08). Current state: D-W-36 and D-W-48 (M0-08 also removes every hit of the history scan, with its own tool per location). Identities in allow-authors.txt that are not the chosen one are removed before the history scan. The product repo names neither the name nor the address. Current state: D-W-93 (what is published is a root commit without parents; the rewrite before the first push is dropped). |
Owner decision (D-L18); the white label in the content is preserved. |
public-switch.md A |
| D-W-07 |
Design accepted on the preview images: dark petrol, yellow accent, working name Bothy. |
Owner acceptance. |
design/previews, D-DS-01 |
| D-W-08 |
Ticket numbers are only names. The build order is set by the dependencies, for M1 by the waves in briefs/README.md; dependencies never point to a later milestone and never form a cycle (PLAN P-19, reworded). Existing dependencies on higher numbers of the same milestone stay. |
The earlier version of P-19 required dependencies only on lower numbers; some tickets violated that. Renumbering would break the names of the briefs and many references without changing the order, which the waves already show completely. |
PLAN P-19, briefs/README.md |
| D-W-09 |
New doctor check HD9: the session user of the host service must not be able to list any home folder of another user; checked as a read test, not via groups. Current state: HD9 checks only home folders of users that are not session users; the home of a session user is checked by HD11 (D-W-88). |
On macOS the default group staff opens other users' home folders; the read test works the same on every platform. |
24 2.2, R-TM-17, TM-T24 |
| D-W-10 |
The integrations from requirement 19 are build tickets: reference app code hosting (M2-49, TypeScript SDK), reference app ticket system (M2-50, Python SDK), cron path with a test in M2-27, alerts in M7-20. Tests use only fake APIs with fixtures from the vendors' documentation. |
Finding L-01 of the critic: 11 only announced the apps, no ticket built them; the requirement asks for them explicitly. Real services in tests would be external accounts. |
11 10, R-EX-11, PLAN 8 |
| D-W-11 |
Installation in one command via the setup wizard install.sh (POSIX shell on the host, release artifact with built-in version and checksum of the bundle), ticket M7-29; the portability test installs with exactly its options (source of the script by phase, D-W-59). No wizard in the container; no random secrets in .env, secrets-init still generates them; off-site stays optional with a warning. |
Finding L-02: requirement 21 and the addendum require one command and a setup wizard; no ticket built it. A container would need the Docker socket (root on the host); secrets in .env would contradict R-OP-05. |
16 3.1, R-OP-20, Installation |
| D-W-12 |
Entry screens E1 to E7 (server address, login, enrollment code, device waiting, pairing, app lock, logged out) shared by all apps; the console uses E2 and E7. |
Finding L-19: M4-18 built these flows without screens. |
01 5.6, R-PR-14 |
| D-W-13 |
Terminal view as a component of the design system with its own roles color.terminal.* (surface, text, cursor, selection, 16 ANSI colors from the existing palette); the roles color.viz.* go into the tokens already now, M6-10 adds the check for color vision deficiency. New preview images terminal and statistics; the accepted images stay unchanged (tokens.py previews --view). Current state: D-W-46 (D-W-41 afterwards regenerated seven accepted images) and D-W-77 (the new images fall under the acceptance). The three grays ANSI 0, 7 and 15 as well as the cursor need contrast ui, all other terminal colors text. |
Finding L-20: the terminal was missing from the design system, color.viz.* stood in components.md without a token. Only an addition: the accepted design (D-W-07) does not change. Full text contrast for all three grays cannot be reached in both themes at once. |
components.md 12, contrast.md, PLAN M6-10 |
| D-W-14 |
Docs home page with a showcase standard (image light and dark, six pillars, quick start, reading path per role); links to repository files are relative, the MkDocs hook builds the URL from repo_url. New gate in make linkcheck: no link outside code to the placeholder repository OWNER. |
Finding L-22: the home page had no image, no quick start and a dead link; lychee checks offline and did not see it. |
Home page, gates.md, tools/docs/placeholder_links.py |
| D-W-15 |
One contract for expired and replaced invocation tokens: the adapter acknowledges after the acceptance into the inbox, not after run.start; a retry with a new token replaces the stored one; a token error at start is always 403 invocation_token_invalid with reason expired, superseded or invalid; an expired token that is still current is renewed by the new action run.token.renew (POST /runs/{id}/invocation-token), which repeats all checks before a delivery. The earlier codes invocation_token_expired (401) and stale_invocation_token are dropped. |
Finding L-07: three incompatible contracts; a start longer than agent.ack_timeout triggered a redelivery whose token replaced the old one and which the duplicate detection discarded, and the run hung until run.queued_max_age. The renewed token is not a new authorization, because the server decides anew. |
09 4.3, 09 4.5, 04 15.2, 06, AG-28, AG-29 |
| D-W-16 |
Tool class in the protocol chapter: claims tool_class and dev_environment_id in 09, check point "reported class not higher than configured class" in the adapter, required field tool_class_used in startRun and resumeRun (server: 409 tool_class_mismatch), reasons tool_class_mismatch, tool_class_owner_only and channel_read_only in 09 5.2; the runner maps chat, scoped, host to --permission-mode and --tools, host is bypassPermissions. |
Finding L-08: 06 promised a 409 at start without a field in the body; unknown reasons would have been stored as adapter_error; the class full did not exist. |
09 4.5, 09 18.2, AG-30 |
| D-W-17 |
With llm_credentials = personal, bothy-host does not touch any subscription login: no token in the state folder, no reading, no handover. The holder of the login logs in themselves in the account of the session user (/login or their own token from claude setup-token in a file of the session user); the profile claude then starts via that user's login shell. Ruling T10 applies more narrowly: the long-lived token remains the path for a user without a login session, but it lies with the session user. |
Finding L-03: the earlier version put the token in the state folder and passed it into the PTY; the product would have stored and brokered a Claude.ai login, against 10 consequence 3 and I5. |
24 7.4, OQ-03, TM-T25 |
| D-W-18 |
The PTY helper is a system service under the session user that the session user cannot change, stop or replace: binary, service definition and channel endpoint owned by root or Administrators, respectively, endpoint from the service manager, verification of the peer by user and process ID, memory protection; new check HD10, HD8 additionally by the service user. Owners see a warning before opening on the host of another person; the audit names foreign_assignee. New threat T-TM-12. |
Finding L-04: a helper owned by the session user could be replaced; check results could be forged and later sessions could be read along. The same user ID remains one trust zone; the warning says so honestly. |
24 2.2, 24 8.4, TM-T26, Threat model |
| D-W-19 |
Authorizer.Scope intersects with ActingFor as with ViewAs; the search of an agent in a run works on the intersection with the grants of the triggering person, also for the candidate limit, candidates_capped and cursor. |
Finding L-05: SR9 requires the intersection for every reading action in a run; without it, the candidate limit and the page marker would reveal something about channels that the triggering person must not read. |
14 4.3, 07 7.4, M1-28, M2-14, M3-14 |
| D-W-20 |
Problem codes are a closed list: chapters, briefs and plan name only codes from x-problem-codes; finer reasons stand in reason (for authorize, the reason code from 07 4.1) or in errors[].code of 400 validation_failed. The codes so far named only in chapters are mapped, for example 413 payload_too_large instead of file_too_large, 409 conflict with reason quota_exceeded, 503 unavailable with reason storage_full or search_timeout, respectively, 400 validation_failed with errors[].code patch_path_forbidden, 403 forbidden with reason owner_only. The check tools/docs/problem_code_refs.py runs in make docs. |
Finding L-10: about a dozen codes and statuses (507, 422 outside idempotency) were attached to no operation; the same class of error without a gate. |
06 5 item 7, R-API-28, openapi.yaml createUpload, uploadPart, completeUpload, bindAgentSession, 12 3, 09, 14 7 |
| D-W-21 |
The enum AppScope contains costs:reconcile; which actions a scope allows is stated only in 05 2.6, chapter 11 now names only the typical use; M1-03 checks the enum against the scopes of the catalogue (sub-check app-scopes). |
Finding L-09: the reconciliation app could neither request nor receive its scope, and 11 differed from 05 on the actions. |
05 2.6, 11 5, Brief M1-03 |
| D-W-22 |
retention.content = none requires a reason in the configuration (retention.content_none_reason); without a reason, bothy config check and the startup log warn. The earlier confirmation in the console with audit is dropped. The job of the class security is named retention.security as in the job catalogue. |
Finding L-12: the confirmation had neither an action nor a storage location, and the value is read only at process start; privacy.retention_sweep stood in no job catalogue. |
20 4.1, R-DP-01, DP-T13, PLAN M7-05 |
| D-W-23 |
webhook.retry_max_age, webhook.secret_rotation_overlap and webhook.receiver_tolerance stand in the value table of 11; webhook.receiver_tolerance with place "Receiver" (recommendation, no server value, no environment variable). A test ensures that value tables stand only in the chapters. |
Finding L-11: the generator reads only docs/spec/; the values were missing from the configuration reference and from the future schema. |
11 16, webhooks.md 9, tools/docs/tests/test_config_reference.py |
| D-W-24 |
Gateway: a separate close code 4018 forbidden for a valid login without permission (for example scope_missing), 4004 now only for invalid credentials; in 04, the fields of hello are named heartbeat_interval_ms and resume_window_ms as in the schema; heartbeat and reauth consume nothing from rl.gateway_connect; the RL class for "no class" is named none in the catalogue as in the API. Terminal: 4101 is named terminal_token_stale; 24 section 13 explains all codes 4100 to 4114; a host service without login within terminal.host_auth_timeout gets 4113. |
Finding L-28: when a scope was missing, 4004 led to a pointless token renewal; names and explanations differed from each other. |
04 6.3 and 6.4, 24 13, asyncapi.yaml, Brief M1-64, PLAN M2-43 |
| D-W-25 |
The day boundary of the statistics is the configuration value stats.time_zone (default UTC). Durable events without a webhook stand with a reason in the list "Not subscribable" of webhooks.md; M1-03 checks that every durable event stands in exactly one of the two places. authorize in 07 has the branch for step-up fresh. bindAgentSession carries expected_current_session_id and take_over. Upload responses in the chapter follow openapi.yaml (200 instead of 204 or 202, respectively). |
Finding L-29 and alignment during finding L-10: planning remnants ("ticket with team E"), a missing rule for 15 event types, pseudocode without the level fresh, fields only in the chapter. |
17 Configuration values, admin-statistics.md 5, webhooks.md 7, 07 4.2, 09 9.4 |
| D-W-26 |
The module terminal is in the module register already in M1: M1-05 creates doc.go, the README and the entry with tables and lists from 02, M1-06 checks it like every module; it is built only in M2 (P-24 now also names M1-05 and M1-06). The module READMEs are aligned with 02 (ports, tables, dependencies, actions, events). |
Finding L-13: M1-05 did not know the module, M1-06 checks the register against 02; the ticket could not turn green. |
M1-05, M1-06, PLAN P-24 |
| D-W-27 |
Briefs name no count that a list next to them or a chapter already fixes; tests read the list or the chapter (modules from 02, deferred actions in M1-28, cases of 05 rule 1.6 in M1-29, rate-limit classes in M1-19). The AZ-P7 case for group.member.sync belongs to M1-42, because the sync of the claim groups only comes into being there. |
Finding L-14: fixed numbers contradicted the lists (twelve instead of all deferred actions, seventeen cases for eighteen actions); the acceptance criteria could not be met that way. |
M1-28, M1-29, M1-42, M1-19 |
| D-W-28 |
A brief calls only make targets that the Makefile already has or that a ticket of its dependency closure creates. New edges: M1-06 and M1-08 before M1-13, M1-03 before M1-17 (instead of M1-02, now transitive), M1-23 before M1-32 (the ticket changes the Dockerfile); M1-23 before M1-73 is thereby transitive. The waves stay the same. tools/docs/brief_deps.py checks in make docs the index, waves, graph, plan column, header lines of the briefs and the table of targets; --write regenerates waves and graph. |
Finding L-15: the launcher starts a ticket as soon as its own dependencies are merged; 36 calls of make config-doc-check, make register-check, make api-checks and make image lay outside the closure. |
briefs/README.md, PLAN 7 |
| D-W-29 |
Briefs check for en and em dashes with tools/hooks/text_rules.py instead of grep -P; every brief with a new migration names make migrations-lock; M1-47 refers to 18 section 2 "Components", M1-63 checks the content of ADR 0059 instead of its existence. |
Finding L-27: grep -P fails on macOS with exit 2 and empty output, which counted as passed; without a lock line the migration lint fails only in the gate. |
M1-01, M1-47, M1-63 |
| D-W-30 |
V1 and V2 have a test strategy and a red team section, V3 has scope, test strategy and red team per ticket; V2 requires DEV-T08 (V2-07). M5-07 uses voice.max_duration; M3-13 builds no second migration lint, but passes the one from M1-01. |
Finding L-26: the sections were missing, the key did not exist, and the lint was planned twice. |
PLAN 15 to 17, PLAN M3 |
| D-W-31 |
Superseded by D-W-92. What remains: signing secrets lie only in the environments release*; jobs with this environment run, like all jobs, on GitHub-hosted runners, each in a new VM, and take no values from other jobs outside of conditions (D-W-52, D-W-72); new threat T-SC-05. |
Finding L-06: a dependency PR must not change anything that a job with signing secrets uses; requirement 15 requires "no secrets beyond what the job needs". |
ADR 0039, R-QA-12, release.yml, Threat model |
| D-W-32 |
Before every push and every local merge, an independent, read-only review of the diff; proof .review/<sha>.md (git-ignored) with the last line Urteil: JA, checked by the pre-push hook tools/hooks/review_gate.py. The overall review per milestone and before going public stays. Contributors from forks switch it off visibly with REVIEW_SKIP=1; the maintainer checks before the merge. Current state: D-W-50 (report as text in the pull request). |
Finding L-16: requirement 23 requires an AI review before every push, PLAN 3.9 one per ticket; the docs named it only per milestone, and make review enforced nothing. The independence itself cannot be checked by machine (RK-85). |
red-team-gate.md, push-discipline.md, R-QA-13, CONTRIBUTING.md |
| D-W-33 |
Documentation in code as a gate per language: ruff D (convention google, tests excluded) for tools/ from now on and for the Python SDK, revive exported for Go (M1-05), public_member_api_docs and dart doc for Dart (M4-01), typedoc for the TypeScript SDK (M2-30); API references of the SDKs and Dart packages in the docs site; the server documents its public interface through openapi.yaml. The build rules 1 to 5 stand in every brief byte for byte as in the block of docs/briefs/README.md; a test holds that. "docs site" instead of "MkDocs" stays: that is the wording of the lead's template, and ADR 0045 keeps the generator replaceable (superseded by D-W-54: the block translates the owner requirements literally). |
Finding L-17: in the tool code, docstrings were missing on most public symbols, no gate checked them, no ticket generated API docs, and the briefs quoted the rules in ten versions. |
23 Documentation in code, R-QA-14, briefs/README.md, tools/docs/tests/test_brief_rules.py, PLAN M2-29, M2-30, M4-01 |
| D-W-34 |
denylist-main.yml checks every push to main (range before..after) with the private list from the environment denylist; it is the only exception to the push ban, only for exactly main, listed in check_runners.py as POST_MERGE_WORKFLOWS. Procedure on a hit: push-discipline.md "Deny-list hit on main". A merge queue with a required check was rejected. |
Finding L-18: fork PRs saw only the generic rules, and after the merge nothing checked; requirement 22 requires a CI job that fails. A squash can also change the message after the checks. Merge queues exist on GitHub.com only for public repositories of organizations and for private ones with GitHub Enterprise Cloud, not for a personal account (https://raw.githubusercontent.com/github/docs/main/data/reusables/gated-features/merge-queue.md, retrieved 2026-10-11) [verified]. |
denylist-main.yml, R-QA-15, ADR 0042 |
| D-W-35 |
Superseded by D-W-92: no rule anymore; the named values and their test are removed. |
The rule applied only to runs in the private state, which does not exist. |
ADR 0039, R-QA-16 |
| D-W-36 |
M0-08 cleans the whole history: every hit of make denylist-history (as of 2026-10-11 only old versions of planning files, hits from the private list and generic rules, none in identities) and the old gitleaks sample values, with one git filter-repo --replace-text over all refs; the replacement file is written by a new scanner option --replacements-out outside the repository. Acceptance: 0 hits. The history is not rewritten in this planning; only M0-08 does that. Squashing the planning branch into one commit was rejected. Current state: D-W-48 (--replace-text alone is not enough; a tool per location). Current state: superseded by D-W-93 (the owner publishes a root commit without parents; the planning history stays private and documents the decisions there). |
Finding L-24: the scope of M0-08 named only the gitleaks sample values, the acceptance required more. The planning history documents decisions; a squash would lose it. |
PLAN M0-08, public-switch.md, RK-77 |
| D-W-37 |
Allowed commit identities count only as author, committer and in a Signed-off-by line that repeats exactly the author or committer of the same commit. Co-authored-by and other people's sign-offs are checked by the private list like any location. Only Signed-off-by and Co-authored-by are identity lines (private list only, because contributors sign off with their own address); Reviewed-by and similar lines are message text with all rules. |
Finding L-25: the scanner excluded six kinds of lines and allowed the identity there completely, against D-W-06 ("only in the author and committer fields"). Applying the generic rules to sign-offs would block every external DCO contribution; this part of the finding is rejected. |
tools/denylist/denylist_scan.py, tools/denylist/README.md, ADR 0042 |
| D-W-38 |
Language: all repository content is English (D-W-94, owner, 2026-10-11). Before: community and showcase files English (README.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, GOVERNANCE.md, SUPPORT.md, CHANGELOG.md, templates under .github/), as well as build briefs, comments and messages in operations artifacts (infra/, D-L5) and tool code; planning documents under docs/ German. |
Finding L-30: the rule stood only in the team instruction, not in the log; infra/ had German comments and German :? messages in compose.yaml, although instance operators worldwide read them. English is the language of the contributors of a public project. |
CONTRIBUTING.md, infra/, D-L5, D-OB-07 |
| D-W-39 |
Diagrams in the design system: tools/design-tokens/tokens.py generates design/build/mermaid.config.json (Mermaid theme base, colors and font of the light theme) and docs/design/site-theme.css (colors of the docs site and its Mermaid blocks per color scheme). make diagrams renders with it and checks that every .svg is rendered with the theme, that no diagram exceeds the aspect ratio MAX_ASPECT and that .mmd files name only token colors. render_diagrams.py --write renders again. |
Finding L-23: all diagrams used Mermaid's default theme with purple, although the design system forbids purple as an accent; erd-6 was about 6:1 and unreadable at page width. |
architecture/README.md, tools/docs/render_diagrams.py, quality/gates.md |
| D-W-40 |
The ERDs live only in docs/architecture/erd-*.mmd, one diagram per module (erd-6 split into erd-6-sync, erd-8-audit-privacy, erd-9-import-status-statistics, erd-10-notification); chapter 03 section 7 only refers to them. tools/docs/erd_check.py (part of make diagrams) checks against 03: every table exactly once in full, every entity a table from 03, every foreign key drawn as often as there are foreign keys between the two tables; the only exceptions are bookkeeping columns (name ends in _by, target principals). |
Finding L-23: the ERDs existed twice (architecture and 03), none was complete (audit_ip_keys, stats_minutes were missing, foreign keys were missing in 03), and nothing checked the promise "every table exactly once". |
03 section 7, tools/docs/erd_check.py |
| D-W-41 |
A separate guard for tinted backgrounds (*.subtle, *.chip-bg): in the warm hue range only with a clearly visible hue (chroma at least the value in design/design-rules.json). color.warning.subtle in the light theme therefore uses palette.amber.100 instead of amber.50. The preview shows run states with the words from components.md instead of the enum values, and the image of the main view no longer cuts off a message below the live box. |
Finding L-30: the cream guard applied only to neutrals and surfaces; as a surface, amber.50 looked like cream. Raw enum names are not UI texts; the cut-off fragment looked like a bug in the showcase image. |
design/design-rules.json, tools/design-tokens/tokenkit/rules.py, design/contrast.md, docs/design/previews/ |
| D-W-42 |
ADR 0045 names the end of support for Material (commitment from 9.7.0 for at least twelve months, so 2026-11-11 at the earliest) and three triggers for the switch to Zensical: Zensical 1.0; a security vulnerability in the shipped site without a fix after the end of the commitment; a required Python or MkDocs version that Material does not support. |
Finding L-30: the ADR planned the switch without a date and without a trigger for the case that Zensical is not mature in time. |
ADR 0045 |
| D-W-43 |
Build rule 3 ("API documentation is generated from code") applies to the server like this: one source, openapi.yaml and asyncapi.yaml (ADR 0013, spec-first); server stubs and clients are generated from it, and the API reference of the docs is produced from these files. For SDKs and Dart packages, the rule applies literally (D-W-33). Owner question OQ-22. |
Finding L-30: the owner's requirement and ADR 0013 read as opposites; build sessions could generate OpenAPI from the routes and thereby overwrite the normative contract. Both paths end at one source; spec-first keeps the contract checks (x-authz-action, lint) ahead of the code. |
ADR 0013, briefs/README.md |
| D-W-44 |
run.token.renew and run.start accept no consumed token (jti equal to runs.started_jti) for a waiting run; repeating a start returns the result only while the run is running. run.token.renew additionally accepts the token from before the last renewal (new column runs.renewed_from_jti; every delivery and every start set it to NULL) and then issues again. The error stays superseded, no new reason. The duplicate number R-AG-22 in 09 is resolved (resumption rule now R-AG-24). |
Findings K2-02 and K2-03: after running -> waiting_input, the consumed start token still stood in runs.invocation_jti; a renewal with it would have continued the run without an answer from a person (against R-AG-08). A lost response of run.token.renew left the run hanging until run.queued_max_age. superseded already asks the adapter for the right thing (start nothing, wait for the token of the response); a new code would only bring more cases. |
09 4.5 and 4.5.1, 04 15.3, 03 runs, 06, openapi.yaml renewInvocationToken, AG-31, AG-32 |
| D-W-45 |
HD6 calls the coding agent only via the absolute path host_service.claude_bin, with an environment of the PTY helper; HD10 checks this file like the binary of the helper. Without a value, HD6 checks only the login locations and warns with auth_status_unchecked. Forgeability through the data of the same user ID stands as a residual risk in T-TM-08 and RK-84. Current state: D-W-57 (HD6 only an additional check, fixed search list, HD10 also for the folder chain and interpreter). |
Finding K2-19: the session user could replace claude auth status from their PATH with a program that conceals the subscription login. A fixed, non-writable path closes this route, as with HD10. |
24 7.4, Threat model T-TM-08, RISKS RK-84, TM-T27 |
| D-W-46 |
After the owner acceptance (D-W-07), D-W-41 regenerated seven accepted preview images (comparison c04ab79 against 0bc7083): components-light and components-dark (run states with words instead of enum values; a code block example now stands in the column "Components on messages"; in the light image, the offline banner with amber.100), layout-light and layout-dark (the message below the live box is no longer cut off, the code block fragment drops out there), mobile-light (offline banner with amber.100), palette-light (swatch of color.warning.subtle) and mobile-dark (only rendering noise of single pixels, no visible difference). These seven images are content corrections within the accepted design (D-W-07) and are accepted: the owner decision "Design accepted on docs/design/previews" applies to the images in this folder; the planning received them in this version on 2026-10-11, after the regeneration on the same day. A re-acceptance is not needed. The images terminal-* and viz-* were added after the acceptance (D-W-13) and are accepted as well (ruling T14, D-W-77). |
Finding K2-07: D-W-13 said "the accepted images stay unchanged"; that was true for its commit, but no longer after D-W-41. Color and shape (dark petrol, yellow accent) stay the same; what changed are one signal token in the light theme and contents of the preview. |
design/previews, D-W-13, D-W-41, D-W-77, Open questions, Decided |
| D-W-47 |
The setup wizard delivers a usable instance in the profile direct: after docker compose up --no-start it enters the gateway of the Compose network as /32 in BOTHY_TRUSTED_PROXIES, then starts and checks GET /api/v1/instance/discovery over the published port. New option --storage fs\|s3 (sets profile s3 and BOTHY_STORAGE_DRIVER); --scan and --observability take on\|off. The command without a terminal (curl ... \| sh -s -- --yes ...) stands verbatim in 16 3.1 and install-compose.md; the portability test runs exactly this command. A second run sets the value only while it is at the default. |
Finding K2-01: with the default (loopback only), the server rejected every request over the published port (SEC-TLS-1); the portability test required s3, the script had no option for it; the command without a terminal was not documented. --no-start creates the network without starting api with a wrong value. |
16 3.1, 16 11.1, install-compose.md 1.1, PLAN M7-29, M7-14 |
| D-W-48 |
M0-08 uses the matching git filter-repo tool per location, all in one run: --replace-text for file contents, --replace-message for commit and tag messages, --paths-from-file with lines old==>new for paths, --strip-blobs-with-ids for binary blobs with a hit, plus --prune-empty never. For this, --replacements-out writes a folder with text.txt, paths.txt and blobs.txt. A fixture repository with hits in all locations proves 0 hits with the same number of commits. Current state: D-W-93 (only needed if a history is published after all). |
Finding K2-12: --replace-text changes only blobs and skips blobs with NUL in the first 8 KiB (source git-filter-repo, function _tweak_blob); messages need --replace-message. A paths file with only renames keeps all other paths (without a filter line the selection is not restrictive). Without --prune-empty never, a commit that contained only a removed blob would be dropped. https://github.com/newren/git-filter-repo/blob/main/Documentation/git-filter-repo.txt and https://github.com/newren/git-filter-repo/blob/main/git-filter-repo, retrieved 2026-10-11 [verified]. |
PLAN M0-08, public-switch.md A, D-W-36 |
| D-W-49 |
make docs checks with tools/docs/register_ids.py that every ID in DECISIONS.md, RISKS.md and OPEN-QUESTIONS.md is defined exactly once and that the overview and the sections of the open questions name the same IDs. The duplicate RK-82 (diagram SVGs) is now RK-87; RK-82 stays with the setup wizard, because it stood there first. Current state: extended to all tables in docs/**/*.md by D-W-60. |
Finding K2-14: RK-82 was assigned twice, and no gate found it. Rule 1 of this log (IDs stable, never duplicate) needs a test. |
tools/docs/register_ids.py, gates.md, RISKS |
| D-W-50 |
The report of the independent review per push goes as text into the section "Review report" of the pull request template; .review/ stays git-ignored. Current state: LG/1 (the report stays local; the pull request names commit and verdict in fresh words). |
Finding K2-18: a git-ignored local file cannot be linked in the pull request. Committing the report would mix it with the reviewed diff and change its own SHA. |
CONTRIBUTING.md, Template, red-team-gate.md, tools/hooks/review_gate.py, D-W-32 |
| D-W-51 |
Values of the tool bothy import (import.*) stand in a separate YAML file with nested keys (option --config) or in the environment by the naming rule, never in the server's file. |
Finding K2-06: an operations template mixed flat dot keys and tool keys in the server file; the server rejects both. Chapter 22 did not name the format of the tool file. |
22 19, 22 10, PLAN M8-04 |
| D-W-52 |
No value of the job resolve (without environment release*) reaches a shell or action of a job with environment release* in release.yml. Each of these jobs resolves the tag again itself with tools/ci/components.py release (path only from RELEASE_COMPONENTS), compares all five outputs with needs.resolve.outputs in an if: and aborts on a mismatch; afterwards it uses only its own result. publish downloads the build artifacts via artifact-ids from the outputs of the release jobs, not by name or pattern. check_runners.py enforces both (rules release-untrusted-output, release-artifact-by-id). Current state: release-untrusted-output is an allow list (D-W-61). |
Finding K2-04: make -C "$COMPONENT_PATH" in the build with signing secrets took the path unchecked from the job resolve; a compromised job without signing secrets could thus have set its own path including a Makefile. The same route existed through the pattern dist-*, because every job of a run can upload artifacts. A comparison in if: does not insert a foreign value into a command. https://raw.githubusercontent.com/actions/download-artifact/9000827ccba6bdab643e8b6fd33ac0654aef8333/action.yml (input artifact-ids) and https://raw.githubusercontent.com/actions/upload-artifact/cf430e030ddbb5b0abf93d22962f4752f3646cd9/action.yml (output artifact-id), retrieved 2026-10-11 [verified]. |
release.yml, tools/ci/check_runners.py, ADR 0039, Threat model T-SC-05, R-QA-12 |
| D-W-53 |
check_runners.py resolves runs-on: ${{ matrix.<key> }} for each combination of the static strategy.matrix (with include and exclude by GitHub's rules); every runner label found this way goes through the same rule. A matrix behind runs-on that is computed only at run time is a finding. The portability test runs as a matrix on ubuntu-24.04 and ubuntu-24.04-arm, as 16 11.1 and M7-14 require. Current state: The rule is named runner-not-github-hosted (D-W-92). |
Finding K2-11: portability.yml ran only on amd64, although 16 11.1 requires amd64 and arm64. ubuntu-24.04-arm is a standard runner for public repositories (https://docs.github.com/en/actions/reference/runners/github-hosted-runners, retrieved 2026-10-11) [verified]. |
portability.yml, ADR 0039 |
| D-W-54 |
The block "Fixed build rules" in docs/briefs/README.md and all briefs is a literal English translation of the owner requirements: "Python API docs in MkDocs", "stages are only the order", no German word. A note after the block explains that "stages" are the milestones and that "MkDocs", after the planned switch to Zensical, means the docs site from mkdocs.yml. test_brief_rules.py keeps the block identical in all briefs and checks these words. Supersedes the "docs site" part of D-W-33. |
Finding K2-13: The owner requirements demand the items word for word; "docs site" and "etappen" deviated. A template of the lead does not take precedence over the owner requirement (precedence: the owner's brief before plan and templates). |
briefs/README.md, tools/docs/tests/test_brief_rules.py, D-W-33, ADR 0045 |
| D-W-55 |
The statistics preview shows for delivery what the own measurement knows: the share of live deliveries within the threshold (25 6.3), with Y-axis values and a dashed target line. The generator reads target and threshold from the SLO delivery-latency in infra/observability/slo/sync.yaml (tokenkit.emit_preview.delivery_slo); a test compares with a second evaluation of the same file. viz-light.png and viz-dark.png are regenerated and accepted (D-W-13, Ruling T14, D-W-77). |
Finding K2-15: The preview named "Target 250 ms" for "Delivery p99"; this value does not exist for delivery (250 ms is perf.catchup_page_p99), and the own measurement stores no percentiles but shares up to the threshold. |
25 6.3, design/previews, tools/design-tokens/tests/test_previews.py, slos.md |
| D-W-56 |
render_diagrams.py --write writes into every .svg, directly after the <svg> tag, a comment with the SHA-256 of the .mmd and of design/build/mermaid.config.json. make diagrams recomputes it and reports every .svg without a stamp or with a different stamp as stale; a test runs the check over all checked-in diagrams. All SVGs are re-rendered and stamped. |
Finding K2-17: The gate checked only theme and aspect ratio; a changed .mmd without a new render was noticed only in review. The stamp depends only on the inputs, not on the browser version, and also detects a theme change; a comparison of the text nodes would not find the theme change. |
tools/docs/render_diagrams.py, quality/gates.md, RISKS RK-87, D-W-39 |
| D-W-57 |
Ruling T12 of the lead (after critique round 3, 2026-10-11): one session user per person and host, never shared, not even after an assignment ends. New table host_session_users (name unique per host across all rows), terminal_sessions.host_session_user_id, guard rule SR12 (without an assignment 403 terminal_denied with reason: no_session_user, also for owners), 409 conflict with reason: session_user_taken, claim osu in the terminal session token, binding of osu to the first person in the host service (session_user_unknown, session_user_mismatch), one PTY helper per session user (host_service.session_users instead of host_service.session_user). Assignment at confirmHost (session_users) and updateHost (add_session_users, revoke_session_users). HD6 stays as an additional check (Ruling T2 still applies), calls the coding agent with a fixed search list as PATH, and HD10 also checks the folder chain and interpreter of host_service.claude_bin. I6, T1 (SR10) and T2 stay unchanged. Supplemented by D-W-62 (Ruling T13, no shared locations) and D-W-63 (reported names). |
Findings K3-01 and K3-06, third round of the same error class after K2-19: An oracle in the account of a shared session user could be forged or (macOS without host_service.claude_bin) was switched off, and a subscription login in it was used by every later session of other people. Separate accounts make "no other person's subscription login" true independently of any check. The proposal from K3-01 to treat the empty value under macOS as an error is rejected: It closes only this instance. |
24 2.2, 24 7.4, 07 SR12, 03 host_session_users, ADR 0068, openapi.yaml confirmHost, updateHost, Threat model T-TM-08, T-TM-11, RISKS RK-22, RK-84, PLAN M2-39 to M2-45, M2-48, TM-T28, AZ-M61 |
| D-W-58 |
run.token.renew checks "not consumed" as runs.started_jti IS DISTINCT FROM jti (NULL counts as not consumed); after the start AG-32 expects 409 already_started, because step 2 (state) checks before step 3 (jti), and only after running -> waiting_input 403 invocation_token_invalid with reason: superseded. |
Findings K3-09 and K3-03: In SQL, jti <> started_jti yields NULL for NULL (run never started yet) and would have rejected every renewal before the first start; AG-32 contradicted the check order from 4.5.1, AG-29 and 04 15.3. |
09 4.5.1, 04 15.3, 05, AG-32, D-W-44 |
| D-W-59 |
Portability test: The options after sh -s -- from 16 3.1 are normative, not the URL. The source of the script is a parameter (PORTABILITY_INSTALL_SOURCE): in the private phase gh release download <tag> (install.sh and bundle) with the job's token and the new option install.sh --bundle <file>, in the public phase the documented curl command. release.yml calls portability.yml (workflow_call) after a successful publish and only for product tags vX.Y.Z; server tags do not start it, a server version is checked by the product release that installs it. runners-check allows only workflow_call and workflow_dispatch for portability.yml; in both start types the job checks out refs/tags/<tag>, so that test code and release match. The public path is proven by public-switch.md step 21. Current state: Only one job of release.yml calls portability.yml, without a matrix (D-W-73); the interface of make portability and the place of the options are in 16 11.1 (D-W-74). Current state: The repository is public from the first push (D-W-92); portability.yml uses only the source url and does not log in to the registry. |
Finding K3-02: GitHub shows releases only with read permission, the anonymous curl fails in the private phase; at a start by tag push, install.sh did not yet exist under the tag, and never under server/v*; OWNER is replaced only by V1-03. This way M7-14 can turn green in the private phase. workflow_call instead of workflow_run: The tag comes from github.ref_name of the same run, and the test starts only after publish. |
16 3.1, 16 11.1, M7-14, M7-29 |
| D-W-60 |
tools/docs/register_ids.py no longer checks only the three registers, but every table in docs/**/*.md: If the first column holds exactly one ID (prefix with -, number at the end, for example R-AG-22, T-SC-05, SEC-TERM-01, ID-T01, M1-23, D-W-01, RK-82, OQ-07, plus decision IDs with a word instead of a number such as D-R-STACK), that is its definition, and it may appear this way only once in the whole repository. A table that only names IDs puts them in a later column. Short local numbers without - (M1, D1, W1) are not IDs in this sense. Current state of D-W-49: extended by this ruling. Current state: also checks the cell count of every table row (D-W-71). |
Finding K3-05: Duplicate IDs occurred for the second time (RK-82 in RISKS, R-AG-22 twice in 09 at efca7f7); D-W-49 covered only the registers. Chapters and log cite IDs without a file (SPEC 13 (Change rule) item 4, rule 1 of this log), so uniqueness applies to the whole repository. The test keeps the old 09 state as a fixture. |
tools/docs/register_ids.py, tools/docs/tests/test_register_ids.py, gates.md, D-W-49 |
| D-W-61 |
The rule release-untrusted-output in tools/ci/check_runners.py is an allow-list: Outside of if:, a job with environment release* may use, in an expression ${{ }}, only needs.<job>.result and needs.<job>.outputs.<name> of another job with environment release*. Every other form of needs is a finding, including object filters (needs.*.outputs.x), toJSON(needs.resolve), index access (needs.resolve['outputs'].path) and other spelling (NEEDS). Current state of D-W-52: enforcement tightened by this ruling. Current state: Only the if: of the job and the if: of each step are exempt; a key if elsewhere and keys themselves are checked (D-W-72). Current state: the rule recognizes release jobs in every spelling of the environment name, an expression as the name is a finding (D-W-110). |
The deny-list recognized only needs.<job>.outputs and toJSON(needs); valid expressions such as filters and index access passed. An allow-list also excludes forms that nobody thought of. |
tools/ci/check_runners.py, tools/ci/tests/test_check_runners.py, D-W-52 |
| D-W-62 |
Ruling T13 of the lead (after critique round 4, 2026-10-11): On a host there is no location that one session user can write and another can read or execute, if it carries code, settings or logins. host_service.claude_workdirs names the working directories per session user; each belongs to exactly one (mode 0700, no folder above it writable by others); the index of the working directory (workdir_index, claim wdi) counts only in the list of the own session user, the host service resolves it only in the folders of osu; with organization, the CLAUDE_CONFIG_DIR of the profile claude is fixed in the home of the session user (.config/bothy-host/claude); programs belong to root or Administrators. A check HD11 checks this with one rule for every configured path and the home including its folder chain, before every PTY and periodically, and blocks only the affected session user (close_detail = shared_location); at a location for all it refuses the start. Shared code only via git. The rationale of RK-22 now names T13 as a prerequisite; new risks RK-88, threat T-TM-13, test TM-T29, control SEC-TERM-13. Current state: HD11 also checks the chain files and the ownership of the programs including their folder chain (D-W-68); the list of working directories applies after HD7 (D-W-69); terms per platform and drive root (D-W-80), program of the profile claude (D-W-81), CLAUDE_CONFIG_DIR with personal (D-W-82), path resolution (D-W-83). |
Shared working directories (project settings with hooks, apiKeyHelper and env, CLAUDE.md in the folder chain) and a shared CLAUDE_CONFIG_DIR connected the accounts separated by T12 again; this is the same error class as T12 at other locations. Instead of one check per location (the critique's proposal: extend HD7 and HD9), one general rule with one check applies, so that the next location does not have to be found individually again. Limit honestly named: HD11 does not see folders outside the configuration. |
24 2.2, 24 5.3, 24 7.4, 16 4.4, 19 SEC-TERM-13, ADR 0068, Threat model T-TM-08, T-TM-13, RISKS RK-22, RK-88, R-TM-22, PLAN M2-42, M2-44, M2-45 |
| D-W-63 |
The host service reports the names of its session users with the display names of their working directories at host.pair and with every report (hosts.reported_session_users, schema host_reported_session_users.v1); after every login it sends report before it accepts session.open. confirmHost and updateHost assign only reported names, otherwise 400 validation_failed with errors[].code = session_user_not_on_host; an assignment takes effect for SR12 only as long as its name is reported, otherwise 403 terminal_denied with reason: no_session_user. 4104 with session_user_unknown remains for a token with a name that the host did not report in its last report. The status number follows 06 (400 for rule violations, 422 only for idempotency_key_reuse), not the critique's proposal 422. |
The server did not know the names; a typo by the owner was noticed only on opening, as a hard tampering warning 4104, contrary to R-TM-16, and the selection in screen A8 had no source. |
24 2.2, 24 5.3, 07 SR12, 03 hosts, 01 A8, openapi.yaml HostReportedSessionUser, pairHost, confirmHost, updateHost, asyncapi.yaml hostControl, R-TM-23, R-DM-41, TM-T28 (e), PLAN M2-39, M2-41, M2-43, M2-44, M4-37 |
| D-W-64 |
On a violation, HD6 and HD11 give the name of the affected session user in hosts.reported_doctor (field session_user); the console also shows the person of its assignment. The standstill of a host through the subscription login of a single person stays (Ruling T2 unchanged) and is recorded as threat T-TM-14 and risk RK-89 with an instance operator step in 16 4.4. Current state: one entry per affected session user (D-W-70); the second instance operator step takes the session user out with session-user remove instead of only locking it (D-W-67); a chain file in a shared folder on the path is a second way to the standstill (D-W-80); an open home of a session user is not one (D-W-88); the names reach the console also on a hit at start (D-W-91); a long-running process that reopens the home of a former session user after remove is a third path, and HD9 names this user; one rule for names applies to every check that a person can make fail, also to HD10 (D-W-109). |
Since T12 a subscription login affects only the account of its person, but a hit of HD6 brings the whole host to a standstill, and doctor did not name the account. The name is no secret; the server knows it from the assignment. |
24 2.2, 24 7.4, 16 4.4, openapi.yaml HostDoctorCheck, R-TM-14, TM-T21 |
| D-W-65 |
The session user of a dev environment is created by provisioning; only an owner with host.manage (step-up, reason, audit) can assign it, when confirming the host. There is no system action for it. |
21 let provisioning assign, but only host.manage assigns, and that requires owner, step-up and reason. A system action that grants access to a host without an owner would be a new way around the permission model; the confirmation needs the owner anyway. |
21 7.1, R-DV-07, DEV-T08, PLAN V2-07 |
| D-W-66 |
terminal_sessions references host_session_users(id, host_id, user_id) with the composite foreign key (host_session_user_id, host_id, user_id) (for this, unique (id, host_id, user_id)). |
A foreign key only on id allowed a session user of another host or another person; a code error would then write someone else's osu into the token, and the host service would bind a new name to the wrong person. |
03 terminal_sessions, R-DM-40, ERD part 7, PLAN M2-39 |
| D-W-67 |
bothy-host session-user remove <name> (with administrator privileges) removes the name from host_service.session_users and its entry from host_service.claude_workdirs, removes the service of its PTY helper and restarts the host service, which reads the list only at start. The binding of the name to its person stays in the state directory, and session-user add rejects a bound name (T12). Current state: remove also closes the home (D-W-88), add also restarts the host service (D-W-90); after remove the instance operator deletes the user together with its long-running processes, locking alone is not enough (D-W-109). The instance operator ends the standstill through HD6 by removing the login or, after the owner has ended the assignment, by taking the session user out this way; locking the user alone is not enough. After the revocation of a person who is not an owner, the console advises taking their session user out and locking or deleting it. |
Finding R5-02: The second way from D-W-64 (end the assignment and lock the user) did not end the standstill by its own rule. HD6 runs in the PTY helper of every name from host_service.session_users, whether it is assigned or locked on the host, and there was no command that removes a session user. |
24 2.2, 24 6.5, 24 7.4, 16 4.4, Threat model T-TM-14, RISKS RK-89, R-TM-24, TM-T21, PLAN M2-42, M2-45 |
| D-W-68 |
HD11 checks the one rule from 24 2.2 item 6 for two kinds of locations. Location of a session user: the location, every folder on the path and, in these folders, the chain files (CLAUDE.md, CLAUDE.local.md, AGENTS.md, .mcp.json and the folders .claude and .git with their content, that is, what the coding agent and git load from the folders above the working directory) belong to no other session user and are writable by no other; checked before every PTY and periodically. Location for all (programs): the location and every folder on the path belong to root or Administrators and are writable by no session user, as with HD10. The limit is now stated correctly: An entry that another session user creates while a location is open is found by HD11 only at the next start of the service or call of doctor; a file that a later version of the coding agent additionally loads from the folder chain, only after the list is extended. Current state: "root or Administrators" now means trusted owner per platform (D-W-80); every path is resolved before the check (D-W-83). |
Findings R5-03 and R5-04. R5-03: 2.2 required ownership by root or Administrators for programs, HD11 only "no session user"; the stricter rule is chosen, because D-W-62, R-TM-22, 16 4.4 and HD10 already name it and HD11 cannot check the permissions of another account. R5-04: A CLAUDE.md in a folder above the working directory that belongs to another person passed HD11, although the rationale of T13 names exactly this file. A rule for all entries of these folders does not work, because the home folders of the other session users are in /home; hence a fixed list with sources (24 2.2), which M2-42 checks against the current documentation. |
24 2.2, 16 4.4, 19 SEC-TERM-13, ADR 0068, Threat model T-TM-13, RISKS RK-88, R-TM-22, TM-T29 (f) and (h), PLAN M2-42 |
| D-W-69 |
The host service reads host_service.claude_workdirs, like host_service.session_users, only at start. A folder that is listed there for two session users drops out of both lists (HD7). pair and report report the lists after HD7, and the host service resolves wdi in exactly the reported list of osu. |
Finding R5-05: It was open which entry drops out for a duplicate folder and whether the reported list is the one before or after HD7. If the server counted the index in a different list than the host service, a person would get a different folder or 4104 with profile_unknown without any attack, contrary to R-TM-16. Out of both lists, because neither side is recognizably the right one. |
24 2.2, 24 5.3, 24 11.1, 03 hosts, openapi.yaml HostReportedSessionUser, TM-T29 (c), PLAN M2-42, M2-43, M2-45 |
| D-W-70 |
hosts.reported_doctor is a list (default '[]', like doctor in the API). If HD6 or HD11 fails, it holds a separate entry with its name for each affected session user; check and name together are unique. Console and audit name each of these session users. Current state: one rule for names applies to every check that a person can make fail; so HD10 with a wrong peer and HD9 with a former session user also name the account (D-W-109). |
Finding R5-08: The data model provided one entry per check with exactly one name. HD6 can trigger in several accounts at once and then brings the whole host to a standstill; the instance operator must know every account (D-W-64). The default '{}' also did not match the list in the API. |
03 hosts, 24 2.2, 24 7.4, 24 10.1, openapi.yaml HostDoctorCheck, 01 A8, 20, TM-T21, PLAN M2-42, M4-37 |
| D-W-71 |
tools/docs/register_ids.py additionally checks that every row of a table in docs/**/*.md has as many cells as the header of its table. It counts cells like Python-Markdown: \| and | in code do not split. T-TM-13 and T-TM-14 are now in register 6.7 of the threat model. |
Finding R5-01: Two rows of the seven-column register 6.7 stood in the five-column STRIDE table 5.16; the renderer dropped the extra cells without a warning, tests and residual risk of both threats were missing, and register_ids.py found nothing, because the IDs were unique. The check keeps the state 4a9ced3 as a test case and counts all rows of the repository the same way as Python-Markdown. |
tools/docs/register_ids.py, tools/docs/tests/test_register_ids.py, gates.md, Threat model 6.7, D-W-60 |
| D-W-72 |
release-untrusted-output skips only the if: of a job (jobs.<id>.if) and the if: of its steps (jobs.<id>.steps[*].if). Every other key if (for example in env of the job or of a step, or in with) is an ordinary value and is checked. The rule checks keys as well, without settling whether GitHub evaluates expressions there (cautious, as with needs in strings). Current state of D-W-61: made more precise by this ruling. |
Finding C4-03: The exception applied to every key if at any depth. A value of the job resolve in env: {if: ...} reached the shell of a job with signing secrets as $if, and in with: {if: ...} an action, without a finding. GitHub evaluates only the two conditions, without passing the value to a shell or action. |
tools/ci/check_runners.py, tools/ci/tests/test_check_runners.py, gates.md, ADR 0039, Threat model T-SC-05, R-QA-12, D-W-61 |
| D-W-73 |
New rule reusable-release-caller in tools/ci/check_runners.py: A release workflow (RELEASE_WORKFLOWS: release.yml, portability.yml) is called only by release.yml, per target from exactly one job and without strategy. Every form of uses with .github/workflows/<file> is recognized (./, $/ and <owner>/<repo>/...@<ref>), because the check does not know the name of its own repository. A manual start (workflow_dispatch) stays allowed. Current state: a start by workflow_dispatch from a workflow is blocked by the rule actions-write (D-W-111). |
ADR 0039 and D-W-59 named release.yml as the only caller, but only the triggers of portability.yml were checked. A pull request workflow with uses: ./.github/workflows/portability.yml stayed without a finding and would have started, on every pull request, a release test that belongs to no release of this run. The same gap existed for a second call and for a matrix on the calling job, which GitHub allows ("Jobs using the matrix strategy can call a reusable workflow", https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows, retrieved 2026-10-11) [verified]; therefore the rule applies to all three ways. |
tools/ci/check_runners.py, tools/ci/tests/test_check_runners.py, ADR 0039, gates.md, 23 4, D-W-59 |
| D-W-74 |
The interface of make portability is in 16 11.1: PORTABILITY_TAG switches on the portability test from tests/portability; without it the target stays the smoke test from M1-73; PORTABILITY_INSTALL_SOURCE (url or release-download) chooses the source of install.sh; with release-download, PORTABILITY_RELEASE_DIR names the folder with install.sh and bundle. An invalid value aborts before the installation. The options after sh -s -- are stated exactly once in tests/portability/install-options, created by M7-29, read by M7-14; the check from M7-29 compares this file with 16 3.1. With url, the test builds the URL from GITHUB_REPOSITORY and the tag, not from the documentation. Current state: the address from which install.sh downloads the bundle is set by release-build (D-W-95); the test itself shows the logs on failure (D-W-96); an invalid value aborts before the test downloads, pulls or installs anything, because it pulls and checks the server images before install.sh (D-W-99). |
portability.yml passed PORTABILITY_TAG and PORTABILITY_RELEASE_DIR, which were described nowhere, and M7-29 required the options in portability.yml, where they are not. M7-29 creates the file, because it is built before M7-14 and its check would otherwise have nothing to compare. Without PORTABILITY_TAG the smoke test stays, because M1-73 and the exit of M1 use make portability that way. The URL is built from GITHUB_REPOSITORY, because the last product tag in public-switch.md step 21 may have been created before V1-03 and then still names OWNER. |
16 11.1, 16 3.1, portability.yml, 23 3, PLAN M7-14, M7-29, D-W-59 |
| D-W-75 |
install.sh takes BOTHY_VERSION (the server version) unchanged from .env.example of the bundle and does not write its own version into it (16 3.1 step 4). The promotion follows from this: A server beta server/vX.Y.Z-beta.N is checked by the portability test of a product beta vA.B.C-beta.M whose bundle sets BOTHY_VERSION to it; the numbers of the two tags are unrelated. Current state: Who writes BOTHY_VERSION and BOTHY_REGISTRY into the bundle and when the build aborts is set by 18 2.1 (D-W-76). |
Finding C4-13: environments-and-flags.md 1.3 coupled server beta and product beta through the same placeholders, although every component has its own version (18 section 1). In the process it turned out that 16 3.1 step 4 wrote .env "with the version" without saying with which; with the version of install.sh (product) the installation would pull a server image that does not exist under this version. |
16 3.1, environments-and-flags.md 1.3, PLAN M7-29 |
| D-W-76 |
The product release writes server version and registry into the bundle (18 2.1, R-RL-09). The server version is the line BOTHY_VERSION= in infra/compose/.env.example of the tagged commit; make release-prep COMPONENT=bothy SERVER_VERSION=<version> writes it only if the tag server/v<version> exists. make release-build takes it over and writes BOTHY_REGISTRY=ghcr.io/<owner> (lowercase, from GITHUB_REPOSITORY_OWNER) into the .env.example of the bundle. It aborts without a bundle on 0.0.0-placeholder or a version without SemVer form, on a server pre-release in the stable product, without GITHUB_REPOSITORY_OWNER, on OWNER in the result and when an image from compose.yaml with ${BOTHY_VERSION} is missing from the registry. M7-29 takes on the build; M7-10 takes on make release-prep (ADR 0060), which no ticket built so far; 18 7 rule 4 names the tag pg18-<version> of bothy-postgres, which so far only compose.yaml named. 16 11.1 rule 3 now says only for the URL of install.sh that the point in time before or after V1-03 makes no difference. public-switch.md step 21 makes the packages bothy-server and bothy-postgres public before the test; portability.yml logs in to the registry only in the private phase, so that the test in the public phase proves the pull without login (16 11.1 step 1). Current state: a server pre-release in the stable product is already rejected by release-prep and the gate release-pins (D-W-98); install.sh gets the download address from the build (D-W-95). |
A reviewer after D-W-75 found: D-W-75 made the value in the bundle load-bearing, but nothing wrote it; make release-build packed the template with ghcr.io/OWNER and 0.0.0-placeholder unchanged, so every installation and M7-14 would pull an image that does not exist. The version in the commit instead of as a build argument, so that the tag alone determines the bundle and the release commit shows the version for review; a second file such as SERVER_VERSION next to the template would be a second source. The registry from the build, because it depends on the location of the repository (fork, V1-03). The check reads the images from compose.yaml, so that a new image is not forgotten. A newly published package in the container registry is private, and a linked package inherits "the access permissions (but not the visibility)" of the repository (https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility, retrieved 2026-10-11) [verified]; without its own step the public installation without login would fail, while the portability test with the job's token would stay green. |
18 2.1, 16 3.1, 16 11.1, 18 7, 23 3, ADR 0060, release.md, environments-and-flags.md 1.3, configuration.md 9, public-switch.md, portability.yml, .env.example, PLAN M7-10, M7-14, M7-29, D-W-75 |
| D-W-77 |
Ruling T14 of the lead (after critique round 4, 2026-10-11): The preview images terminal-* and viz-* apply the design system accepted by the owner (same tokens, same rules) and fall under its acceptance; there is no open owner question about them. They were created after the acceptance of 2026-10-10: both pairs on the same day with D-W-13, viz-* regenerated on 2026-10-11, last with D-W-55. OQ-23 is closed and is listed with its ID under "Decided"; the ID is not reassigned. The row on the seven regenerated images (D-W-46) names as its source the owner decision "Design accepted on docs/design/previews" with the day on which planning received it in this version (2026-10-11), instead of "Owner, 2026-10-10"; D-W-46 and D-W-55 name the images accepted without reservation. |
Finding C4-10: The owner decision applies to the folder docs/design/previews, in which terminal-* and viz-* lie; OQ-23 nevertheless kept it as an open question and said without evidence that the owner had not seen them. The seven images were created only on 2026-10-11; an owner statement dated 2026-10-10 about them was an inference of planning. |
Open questions, Decided, design/previews, D-W-13, D-W-46, D-W-55 |
| D-W-78 |
The ranking "The five biggest risks" in RISKS.md follows its three criteria with a fixed reading. Criterion 2 means: A later fix does not undo the consequence (lockout, publication, leak, content, knowledge or a shell with someone without a permission for it, legal violation); outages, breaks at interfaces, effort and restrictions do not meet it. Criterion 3 measures the remainder by the "Residual risk" level of the threat model and, at the same level, by its reach. It follows: RK-77 (high, high) is at rank 2, RK-02 falls behind rank 5; every risk with the same rating as ranks 3 to 5 is named with the deciding criterion. A new tool tools/docs/risk_ranking.py in make docs checks criterion 1: same rating as in the register, ranks without a gap, no rising rating, no higher-rated risk outside the table. Criteria 2 and 3 remain a judgment call of the maintenance at the end of every milestone. Current state: Criterion 3 has a rule for risks without an entry in the threat model, and the tool also checks the list behind the last rank (D-W-79). |
Finding C4-14: Rank 5 (RK-07) was justified only against RK-02. The critique proposed placing RK-33 and RK-49 before RK-07 by criterion 2; this is rejected, because knowledge with another audience group is also irreversible and RK-33 and RK-49 have less remainder by criterion 3 (T-BK-02 and T-LG-01 low). The check showed a second error in the same ranking: RK-77 has been at high and high since the history scan, while the list kept saying RK-01 was the only such risk. Ratings change at every maintenance; only a test keeps criterion 1 and register in line. |
RISKS, tools/docs/risk_ranking.py, gates.md, RK-07, RK-77 |
| D-W-79 |
Criterion 3 of the ranking in RISKS.md also applies to risks without an entry in the threat model: The rationale names a level on the same scale with a reason, and it counts like a level of the threat model (RK-77 and RK-50 low). Besides the remainder in the server, T-AI-08 lists the remainder in the adapter with medium: The evidence of the reported mode with Hermes Agent depends on a non-public interface (RK-13); with that, rank 3 follows from the fixed reading. tools/docs/risk_ranking.py additionally checks the list after the last rank (line **Behind rank <n>** up to the next heading): It carries the number of the last rank, and the IDs in the bold start of its list items are exactly the risks outside the table with the rating of the last rank, each once. Which criterion decides between risks of the same rating remains a judgment call of the maintenance. |
Findings RT-C4R-02 and RT-C4R-03 (reviewer after D-W-78): T-AI-08 rated only the remainder in the server, the rationale of rank 3 therefore deviated from the fixed reading, and a rule was missing for risks without an entry in the threat model (RK-77, RK-50). The list behind rank 5 named its risks by hand; a risk raised to this rating left make docs green. This is the same error class as RK-77 before D-W-78 (rating changed in the register, ranking not updated); therefore the same tool with tests now holds it. |
RISKS, Threat model T-AI-08, tools/docs/risk_ranking.py, gates.md, RK-13, RK-50, RK-77 |
| D-W-80 |
HD7, HD10 and HD11 use fixed terms per platform (24 2.2 item 6). Trusted owners: Linux and macOS root, Windows SYSTEM, Administrators and NT SERVICE\TrustedInstaller; every other account does not count, not even that of a person with administrator privileges. "Writable" means, for files, changing or deleting content, attributes, permissions or ownership, for folders additionally creating, deleting or renaming entries: under Linux and macOS w from the mode, under Linux also from the ACL; under macOS in addition the ACL permissions write and append (files), add_file, add_subdirectory, delete_child (folders), delete, writeattr, writeextattr, writesecurity, chown; under Windows FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY, FILE_DELETE_CHILD, FILE_WRITE_DATA, FILE_APPEND_DATA, FILE_WRITE_ATTRIBUTES, FILE_WRITE_EA, DELETE, WRITE_DAC, WRITE_OWNER for the session user or a group in its token; entries only for inheritance do not count for the folder itself. Current state: permissions for files and attributes (D-W-86). Under Windows, bothy-host install, after confirmation by the instance operator, removes the default entry Authenticated Users:(AD) at the root of the system drive and of every drive with a location; install --drive-root <drive> performs this step for a drive added later. Without confirmation HD11 reports drive_root_writable and doctor refuses the start. The command leaves the inheritable entry Authenticated Users:(OI)(CI)(IO)(M) in place. Current state: The command writes the DACL of the root without propagation to the folders below (D-W-87). A chain file of a person in a shared folder on the path is a second way to the standstill (T-TM-14, RK-89). Since then, the terms "root or Administrators" in older rows of this page mean a trusted owner. |
On a Windows host with default permissions HD11 never passed, because C:\ allows all logged-in users to create folders and the system folders belong to TrustedInstaller; if "writable" was read without creating, a person could lock out every other person with C:\.git, the pattern of the gap in Git for Windows (sources in 24 2.2). The chosen option is removing only (AD): It applies to the root alone and changes nothing below. Windows would propagate the removal of the inheritable entry to every existing folder below and take write permissions from programs; the folders that inherit it are found by HD11 when they lie on the path to a location. The owners of the system folders under Windows are only assumed; M2-42 measures them before the build. |
24 2.2, 16 4.4, 19 SEC-TERM-13, ADR 0068, Threat model T-TM-13, T-TM-14, RISKS RK-88, RK-89, R-TM-25, TM-T29 (f) and (i), PLAN M2-42 |
| D-W-81 |
With organization, the profile claude starts exactly the resolved path of host_service.claude_bin, with the fixed search list of HD6 at the front of PATH; if the value is empty, the host service does not offer the profile (HD7, warning). Under Windows the value is an .exe. With personal it starts claude through the login shell of the holder; they choose program and folder, because only they get sessions there (I6). host_service.claude_bin is listed as the program of the profile among the locations for all. Current state: If the search list does not find the interpreter of a script, the value counts as empty for HD6 and the profile; the search list starts with the configured folder, and HD10 checks each of its folders (D-W-85). |
T13 justifies the locations for all with the fact that a program runs in every session user, but did not fix the program of the profile claude; a claude from the PATH could lie in a folder that belongs to another account, and neither HD10 nor HD11 checked it. With a fixed program, HD10 checks exactly what runs. A batch file under Windows carries no #!; its interpreter would remain unchecked. |
24 7.4, 24 11.1, 16 4.4, R-TM-26, TM-T28 (d), TM-T29 (f) and (l), PLAN M2-45 |
| D-W-82 |
With personal, the CLAUDE_CONFIG_DIR of the profile claude from the login shell of the holder must, after resolution, lie in the home of their session user; without a value, the default folder in the home applies. The PTY helper queries the value before every start in a separate login shell and sets it in the start command back to exactly this value. If it lies outside, the profile does not start (open_failed with shared_location); the profile shell remains. |
24 2.2 item 6 counted this folder among the locations of a session user, but HD11 did not know it with personal. Outside the home and readable by others, the holder's subscription login reached long-running processes of other session users and, after a switch to organization, other people. In the home, the check of the home by HD11 protects it. Limit: The value comes from the holder's startup files; the check catches a misconfiguration, not the holder themselves. |
24 2.2, 24 7.4, R-TM-26, TM-T29 (k), PLAN M2-45 |
| D-W-83 |
HD7, HD10 and HD11 fully resolve every path before the check (Linux and macOS like realpath, Windows GetFinalPathNameByHandle). HD10 and HD11 check every folder of the resolved chain, every component of the path as it is configured, and every link that the resolution follows, together with its folder; a link counts like an entry of its folder and like its target, also as a chain file. HD7 compares working directories by their identity in the file system (device and inode, under Windows serial number of the volume and file ID). Profile and HD6 use the resolved path. Current state: who resolves, what applies to a path that cannot be resolved, and the 128-bit file ID under Windows (D-W-84). |
Whether the checks resolve paths was stated nowhere. A check without resolution would let through a link owned by root that points into a folder of another person, and HD7 did not recognize the same folder under two spellings as a duplicate. The comparison by identity covers links and upper and lower case in one step. |
24 2.2, R-TM-25, TM-T29 (j), PLAN M2-42 |
| D-W-84 |
HD7, HD10 and HD11 resolve every path with the user who is allowed to open it: the locations of a session user (home, CLAUDE_CONFIG_DIR, working directories) with its PTY helper, the locations for all with the service user. For HD7, each helper reports to the host service over the local channel, for each working directory of its list, only that directory's identity (device and inode, under Windows serial number of the volume and 128-bit file ID from FILE_ID_INFO); the host service compares the values. A path that cannot be resolved (it is missing, a link points nowhere, a folder on the path is closed) counts as not present: A working directory drops out of the list of its session user, a profile command is dropped (HD7, warning), host_service.claude_bin counts as empty for HD6 and the profile claude, a CLAUDE_CONFIG_DIR with personal counts like one outside the home (shared_location), and a home that cannot be resolved makes HD11 fail for its session user. |
Under T13, the locations of a session user are accessible only to that user; with mode 0700, neither the service user nor the helper of another user can resolve them, and it was open whether such a folder drops out of the list, HD11 fails or the folder stays unchecked. A folder that is never offered connects no accounts; blocking the whole session user would be more than necessary. A link that points nowhere must not count as present, because someone else can create its target later. According to Microsoft, the 64-bit file ID is not unique on ReFS; the 128-bit ID from FILE_ID_INFO is (sources in 24 2.2). |
24 2.2, 24 7.4, 16 4.4, R-TM-25, TM-T29 (j) and (k), PLAN M2-42, M2-45 |
| D-W-85 |
The search list of HD6 and of the profile claude starts with the folder in which host_service.claude_bin is located according to the configuration (resolved), not with the folder that a link points to; HD10 checks every folder of the search list. If the search list does not find the interpreter of a script, host_service.claude_bin counts as empty for HD6 and the profile (HD10 still checks file and folder): HD6 warns with auth_status_unchecked, and with organization there is no profile claude (HD7, warning). |
Until now, in this case only HD6 called nothing. The profile started anyway, and because its PATH continues after the search list, #!/usr/bin/env node found an interpreter that HD10 never checked, for example in the folder of a package manager owned by another account; this interpreter would run in every session user. A PATH made only of the search list would close this as well, but would take the person's other tools away from the coding agent [derived]. The configured folder is unambiguous and already lies on the path that HD10 checks; since paths have been resolved, it was open whether it or the folder of the target is meant. HD10 checks every folder of the search list, so that nobody places another interpreter in front of the checked one. |
24 7.4, 24 2.2 HD7 and HD10, 24 11.1, 24 17 host_service.claude_bin, R-TM-26, TM-T27, TM-T29 (l), PLAN M2-42, M2-45 |
| D-W-86 |
"Writable" under macOS also covers the ACL permissions write and append for files and writeattr and writeextattr for all entries, under Windows also FILE_WRITE_ATTRIBUTES and FILE_WRITE_EA; for files, the general rule names changing attributes. |
The list per platform is what the build implements. Without append, an ACL would pass with which one person appends instructions to a CLAUDE.md above the working directory of another; under Windows the counterpart FILE_APPEND_DATA was already in the list. Attributes count as well, because no source says which of them are without consequence for loading and starting: One permission too many only blocks, one too few opens a location. The same applies under Windows, so that both platforms have one rule; that the default permissions of C:\Users, C:\Program Files and C:\Windows\System32 give these permissions to no session user is assumed and is measured in M2-42. |
24 2.2, R-TM-25, D-W-80, PLAN M2-42 |
| D-W-87 |
bothy-host install (also with --drive-root) removes Authenticated Users:(AD) by rewriting the DACL of exactly the drive root with SetFileSecurityW, with all other entries in the same order and with the same control bits; never with SetNamedSecurityInfo or SetSecurityInfo. The instance operator removes the entry only with this command. TM-T29 (i) compares the security descriptor of every direct subfolder before and after, among them a test folder that a propagation of the inheritance would change. |
According to Microsoft, SetNamedSecurityInfo propagates the inheritable entries to all existing children every time a DACL is set and applies the inheritance model to everything below; at C:\ that would be the whole system drive, with reordered entries, and the promise from 16 4.4 that no permission below changes would not hold. For SetFileSecurityW the documentation says explicitly that nothing goes to the children; Microsoft calls it obsolete, but the behavior is documented only for this call (sources in 24 2.2). A test folder with an inherited entry before one of its own makes a propagation visible in the comparison, which on a fresh system would otherwise leave hardly any traces [derived]. |
24 2.2, 16 4.4, R-TM-25, D-W-80, TM-T29 (i), PLAN M2-42 |
| D-W-88 |
HD9 checks only home folders of users who are not session users (including that of the service user and that of a former session user), still with "Start refused"; the output of bothy-host doctor on the host names the user, only the code goes to the server. The home of a session user is checked only by HD11, as that user's location: If it gives another session user read or execute permission, only this session user gets no sessions, and the message names them. bothy-host session-user remove closes the home of the user that it takes out (mode 0700, under Windows an ACL only for that user and the trusted owners); install --session-user and session-user add create the home this way. Current state: a long-running process of a former session user can reopen its home after remove; the message to the server therefore names this user, and the instance operator deletes the user afterwards (D-W-109). |
HD9 and HD11 checked the same state with different consequences. If a person opened their own home, HD9 triggered in the helper of every other session user and, at the next start, refused the start of the whole host without naming the person; such a start also follows session-user remove. A person could bring the host to a standstill this way, and the consequence of HD11 for the home never took effect. An open home is an error in one account, and the consequence now affects only that account. The home folder of a user who is not a session user cannot be changed by any person with a terminal; remove closes the home of a former session user, because HD9 checks it afterwards. |
24 2.2 items 3 and 4, HD9, R-TM-17, R-TM-24, 16 4.4, ADR 0068, Threat model T-TM-04, T-TM-14, RISKS RK-89, TM-T21 (d), TM-T24, TM-T28 (d), PLAN M2-42 |
| D-W-89 |
Consequences of HD11 during operation: A violation at a location for all ends all running sessions with host_policy_violation and close_detail = shared_location and allows no new ones until HD11 passes; the message names no session user. The check of the entries runs per session user in its PTY helper; at start, only this session user waits for its run (code entry_scan_running), on command the previous result applies until the end. host_service.entry_scan_timeout limits a run; after that, HD11 fails for this session user (entry_scan_timeout). A finding of this check, including an abort, is lifted only by a later full run (start or bothy-host doctor), never by a periodic one. bothy-host doctor performs the full run in a running host service; if it finds a violation with "Start refused", except for HD6, HD10 and HD11, the host service exits as with a refused start. Current state: with entry_scan_timeout the person cannot reach their home without a session; the instance operator raises the value or makes the home smaller themselves (16 4.4 item 6). |
HD11 runs before every PTY and periodically, but for a location for all only "Start refused" was stated; HD10 governed operation, HD11 did not. The check of the entries runs only at start and on command, but the block applied "until HD11 passes", and whether a periodic run lifts it was open; a periodic run does not see the entry and therefore must not declare it settled. Its duration depends on the size of the home; without a limit, the whole host would wait for the largest home. An aborted run has not seen every entry and therefore does not count as passed; the consequence affects only its session user. |
24 2.2 HD11 and "check of the entries", 24 17 host_service.entry_scan_timeout, R-TM-28, 16 4.4 item 6, RISKS RK-90, TM-T29 (e) and (f), PLAN M2-42, M2-45 |
| D-W-90 |
bothy-host session-user add restarts the host service like remove; afterwards the host reports the name with the next report, and the owner assigns it without a further step. Every other change to the configuration of the host service (working directories, profiles, host_service.claude_bin) takes effect after bothy-host restart. Every restart of the host service ends all sessions of the host: Every PTY helper ends its PTYs as soon as its channel to the host service breaks off; on the control connection, identify names the sessions that the host service still knows (live_sessions), and the server ends every other session of the host in active or detached with the new close_reason host_restarted. |
The host service reads its configuration only at start. After add, the server rejected the new name with session_user_not_on_host until someone restarted by hand, and for working directories there was no way. What a restart does with running sessions of other people was open, and TerminalCloseReason had no matching value. Letting the PTYs keep running was rejected: The new process checks everything again at start and reads the configuration again; a session from the time before would keep running unchecked under the old configuration, for example in a removed working directory, and its scrollback buffer lay in the old process. The list in identify needs no state in the server and distinguishes a restart from a network drop. |
24 2.2 items 3 and 5, 24 6.2, 24 10.3, R-TM-27, 16 4.4, 03 terminal_sessions, openapi.yaml HostIdentify, TerminalCloseReason, asyncapi.yaml hostControl, ADR 0068, TM-T16, TM-T21 (f), PLAN M2-42, M2-43, M2-45 |
| D-W-91 |
bothy-host doctor prints on the host, per check, the result and code, for HD6 and HD11 the names of the affected session users, for HD9 the user and for drive_root_writable the drive, never content or values; the exit code is not 0 as soon as a check fails. If doctor refuses the start at start, the host service starts no PTY and accepts no session, but reports first if it is paired: login, one report, then it closes the connection and exits with its own exit code, after which the service manager does not restart it by itself; after the fix, the instance operator starts it with bothy-host restart. Ruling T2 stays: On a contradiction with the declaration there is no session. |
The promise that the console names every affected session user applied only to a hit during operation. If HD6 triggered at start, for example after a restart of the host or after session-user remove, there was no report; the console showed the host only as disconnected, with the result from the time before, and what doctor prints on the host was not defined. A restart by the service manager fixes none of these violations and only logged the host in again and again in a loop. |
24 2.2 "Output on the host" and "Message on a refused start", 24 7.4, R-TM-29, 16 4.4 items 2, 3 and 6, openapi.yaml HostReport, asyncapi.yaml hostControl, Threat model T-TM-14, RISKS RK-89, TM-T21 (g), PLAN M2-42, M2-43 |
| D-W-92 |
Owner, 2026-10-11: The repository is public from the first push, and every workflow job runs only on GitHub-hosted runners with a fixed label; there are no self-hosted runners. check_runners.py rejects every other runner (rule runner-not-github-hosted); the switch in runs-on and everything that served only it (rules, named values, guides) is removed. Public-only jobs keep their condition (R-QA-04). |
Contributions flow in one after another as pull requests into a public repository, checked by CI, SonarQube Cloud and CodeRabbit; the standard runners are free there, and a self-hosted runner would run code from other people's pull requests. |
ADR 0039, public-switch.md, R-QA-03, R-QA-16 |
| D-W-93 |
Owner, 2026-10-11: The first push is a single commit without parents with the checked state (plan M0 and M1-01); the planning history stays private and is not rewritten. M0-08 (git filter-repo) is only needed if a history is to be published after all. Before that, the deny list and gitleaks run in a fresh clone of only this commit. |
A commit without history contains no old file versions, messages or identities that a rewrite would have to find and replace. |
public-switch.md A, PLAN M0-08, D-W-36, RK-77 |
| D-W-94 |
Owner, 2026-10-11: All repository content is English: every Markdown document (specification, ADRs, ruling log, plan, risks, open questions, roadmap, design, quality, operations, observability, compliance, security, build briefs, and the READMEs of modules, services, SDKs, plugins and apps), diagram labels, code comments, docstrings, tool messages, templates, test names and test messages. German stays only in proper names, in the verdict tokens Urteil: JA and Urteil: NEIN that the review gate reads (D-W-32), in product strings of the de locale that a test holds verbatim (marked with language-check: off and language-check: on), and in the term list glossary.md (German term to English term). make docs fails on German prose (language_check.py, exceptions in language-allow.txt). Supersedes D-L5, D-API-10, D-AG-22, D-OB-07 and the language part of D-W-38; R-API-15, R-OB-09 and ADR 0001 follow. English takes precedence over every older rule that names German, as AGENTS.md (the rules for AI coding agents) states. The languages of the product interface (German and English, 01 section 9) do not change. |
Owner, 2026-10-11: "Almost everything in the repository is German; the instruction was English." The repository is public, and its contributors and instance operators read English. |
glossary.md, CONTRIBUTING.md, ADR 0001, R-API-15, R-OB-09 |
| D-W-95 |
make release-build sets into install.sh, next to version and SHA-256 of the bundle, the download address https://github.com/<GITHUB_REPOSITORY>/releases/download/v<version>/; from there install.sh downloads bundle and signature (16 3.1 step 2). It aborts without GITHUB_REPOSITORY and if the finished install.sh still contains OWNER or a placeholder of the source (18 2.1 rules 3 and 4). M7-29 tests both against the fixture repository. |
16 3.1 said which version install.sh downloads, but not from where; only version and checksum were built in. If the source names OWNER/bothy, the public run in public-switch.md step 21 with a tag from before V1-03 would have failed when downloading the bundle, although D-W-74 already made the URL of install.sh itself independent of that. The address comes from the build like the registry, because it depends on the location of the repository, not on the commit. |
18 2.1, 16 3.1, 16 11.1, R-RL-09, R-OP-20, release.md, PLAN M7-29, D-W-74 |
| D-W-96 |
The server images of an installation come only through BOTHY_REGISTRY and BOTHY_VERSION in the bundle and are pulled by tag, not by digest; this also applies to the portability test (16 11.1 step 1, M7-14). Before the start it checks the signature of every pulled digest and names the digests in the result; current state: D-W-99 fixes the path and scope of the check. Between staging and prod the same version moves, and prod compares the digests with those from staging (16 6.1 rule 2); the rest, a movable tag, is recorded as RK-91. The test itself shows the logs on failure before the cleanup, for exactly the installation of this run (16 11.1 interface rule 5); portability.yml has no log step of its own any more. |
16 11.1, 16 6.1 and M7-14 named images by digest, but no step fixed a digest: the bundle pins only the version, and compose.yaml names the images by tags. Digests in the bundle would have been a second source per image next to BOTHY_VERSION, with changes to compose.yaml, install.sh and the bundle; signature check and comparison of the digests cover the moved tag, and RK-91 keeps the rest visible. The old log step called docker compose -f infra/compose/compose.yaml logs in the checkout; with PORTABILITY_TAG, however, the stack runs in the folder of install.sh under its project and is gone after the cleanup. Only the test knows folder and project, like smoke.sh in the smoke test. |
16 6.1, 16 11.1, 18 2.1, 18 10, ADR 0036, environments-and-flags.md, portability.yml, PLAN M7-14, RK-91 |
| D-W-97 |
Precondition portability test per tag (release.md 1 item 2): a stable server needs the green test of the product beta whose bundle pins its last beta. Server beta and product beta have no precondition from the test; their test runs after the release (for the server beta the test of the next product beta that pins it). A stable product and every other component need the test of the last product beta, as soon as there is one. |
The old version required, for every server release, the test of a product beta whose bundle pins this version or its last beta beforehand. For a server beta that is impossible: a bundle can pin it only once its tag exists (release-prep aborts otherwise), and before beta.1 there is no beta. Likewise, the first product beta would have required an earlier one. The test checks the beta afterwards (environments-and-flags.md 1.3), the stable version waits for it. |
release.md, environments-and-flags.md 1.3, D-W-75 |
| D-W-98 |
That a stable product pins no server pre-release shows up before the commit, at the latest in the required check ci-ok of the pull request with the release commit, that is before the tag. The maintainer sets the tag after the merge on the squash commit on main; its push alone starts no check, because the commit is already on origin then and pre-commit runs no pre-push hook for a push without new commits (evidence in 18 2.1 rule 1). make release-prep COMPONENT=bothy rejects without CHANNEL=beta a SERVER_VERSION with a pre-release identifier and names the value, likewise a missing SERVER_VERSION while the line carries the placeholder (test in M7-29, where the option is built). New gate release-pins (tools/ci/components.py pins, part of make gates, so of make check, pre-push and CI): exactly one line BOTHY_VERSION= in infra/compose/.env.example, SemVer without v or the placeholder, and no server pre-release if VERSION carries no pre-release identifier. make release-build keeps the check as a second safeguard. |
So far only make release-build checked this in the release chain, that is after commit, signed tag and push; a typo cost a published product tag that cannot be taken back. release-prep knows CHANNEL and writes the line, so it checks there; the gate also covers a change by hand and runs already today, because it reads only the committed state. The gate lets the placeholder pass, because the template carries it until the first product release; release-prep and release-build reject it. |
18 2.1, R-RL-09, ADR 0060, release.md, environments-and-flags.md 1.3, configuration.md 9, gates.md, push-discipline.md, 23 2, tools/ci/components.py, tools/ci/tests/test_components.py, Makefile, PLAN M7-29, D-W-76 |
| D-W-99 |
The portability test pulls and checks the server images itself before install.sh (16 11.1 step 1): it reads BOTHY_REGISTRY and BOTHY_VERSION from the .env.example of the bundle of the tag (source release-download from PORTABILITY_RELEASE_DIR, source url downloaded from the download address of the release, built from GITHUB_REPOSITORY and the tag), pulls every image that the compose.yaml of the bundle names with ${BOTHY_VERSION}, and checks the signature of each of these digests with cosign as a container with a digest pin. Only then does it call install.sh; Compose takes the local images. After the installation it compares the images of the containers of this installation with the checked ones and fails on a difference. It does not check third-party images (virus scanner, object storage, observability) for a signature of the release chain. make portability thus covers steps 1 to 6 without runner and status check (16 11.1 interface rule 1). |
D-W-96 required the check before the start, but install.sh pulls and starts in one run without a point of intervention (16 3.1 step 5), with the source url the test did not have the bundle beforehand, and rule 1 of the interface named only steps 2 to 5. A check only after the start would have let an unchecked image run; an instance operator checks before use (release.md 5). Compose without pull_policy and build pulls only missing images (evidence in 16 11.1), so the installation starts the checked ones; the comparison afterwards catches another bundle under the same address or a second pull. cosign as a container, because the runner brings only Docker. The release chain signs only the images with ${BOTHY_VERSION}; the others are third-party images that carry no signature of the release chain. |
16 11.1, 16 3.1, 18 2.1, ADR 0036, portability.yml, PLAN M7-14, RK-91, D-W-74, D-W-96 |
| D-W-100 |
Decision of the owner of 2026-10-11: the briefs of milestone M2 (then M3) are written before M1 is closed, in the format of the M1 briefs, in English (P-16) and in the same brief index with waves across all milestones. Such a brief starts as soon as every ticket of its line "Depends on" is merged (P-01); it does not wait for the end of the milestone before it. It names interfaces from M1 as the M1 briefs and the code already merged name them. Each of these briefs carries under "Boundaries" the limit: if the merged code of an M1 ticket differs only in names or paths, the build session follows the code and names every difference in the report; if it differs in behavior from the specification, it ends with Verdict: BLOCKED. |
The build sessions of M1 run in parallel and would soon run out of work otherwise. The brief index so far planned to write the briefs of a milestone only after the previous one was closed, because they build on the delivered state; the limit catches this difference without building a deviation silently (P-11). |
briefs/README.md, PLAN 2, PLAN 19 P-01, P-16 |
| D-W-101 |
Every brief of the terminal block (M2-38 to M2-48) has under "Fixed requirements that apply" the group "Terminal security requirements (D-W-101)" with seven items: permissions per host (capability terminal, SR10, SR11 with I6, SR12), fresh step-up (device key in the apps, in the console only a passkey), network, audit without content, idle time and maximum duration, service and session users without administrator rights, threats from section 6.7 of the threat model with a test. Per item the brief names what the ticket enforces and checks, or which ticket owns the item. The planning reads the item "Tailscale only" from the owner's requirement as "no public inbound path to the terminal": the host service opens only outbound connections (R-TM-01), an installation with the profile tailscale reaches the server only through the tailnet with a policy for port 443 only and without Funnel (D-OP-03); the product does not require Tailscale. Whether the terminal should be reachable only through a private network is OQ-24. |
The owner's requirement asks for these items in every terminal brief. A terminal only through Tailscale would contradict requirement 21 ("no Tailscale requirement") and ADR 0068; that is a product decision of the owner, not of the planning. |
24, 07 SR10, 08 6.3, tailscale.md 3, Threat model 6.7, PLAN 18 requirement 21 |
| D-W-102 |
The column "Depends on" of M2-39, M2-40, M2-41, M2-43, M2-44, M2-45, M2-46, M2-49 and M2-50 names only the transitive reduction, as for M1: M2-39 depends on M1-48 (M1-29 and M1-32 follow from it), M2-40 on M2-39 and M1-49, M2-41 on M2-40, M2-43 on M2-42, M2-44 on M2-43, M2-45 on M2-44, M2-46 on M2-45, M2-49 on M2-25 and M2-30, M2-50 on M2-49. The order does not change; the brief names the dropped dependencies under "Read first". |
The index lists only minimal dependencies, and tools/docs/brief_deps.py requires the column in the plan to equal the index; the dropped tickets were already in the closure of another dependency. |
PLAN 8, briefs/README.md, tools/docs/brief_deps.py |
| D-W-103 |
The column "Depends on" names for five M2 tickets what their brief really builds on (review of the briefs of 2026-10-11): M2-22 depends on M2-12 instead of M2-09, because the cost booking updates the sum of the agent session and needs the foreign key cost_entries.agent_session_id to agent_sessions (03, 10 12.1); M2-25 on M2-15, M2-17 and M2-26, because commands and interactions for apps use the autocomplete (M2-17) as well as signature, envelope and host rules of the outgoing webhooks (M2-26); M2-26 also on M1-56, because webhook_subscriptions.agent_id points to agents (03 4.11) and AZ-M49 needs message.created from a channel; M2-27 on M1-19, M2-05 and M2-24, because the inbound path needs rl.webhook_in (M1-19), channels, threads and messages and, for the proof "never triggers" (AZ-M53), the run subscriber from M2-05; M2-28 gets M2-12 through M2-22 (10 12.4 item 4). Index, waves and graph are regenerated with tools/docs/brief_deps.py --write; the order of the other tickets stays. |
The launcher starts a brief as soon as every ticket of its line "Depends on" is merged (P-01, D-W-100); without these edges each of the five briefs would have started too early and could only have ended with Verdict: BLOCKED. |
PLAN 8, briefs/README.md, tools/docs/brief_deps.py |
| D-W-104 |
The core contract is amended minimally and additively for briefs of the milestones M2 and M3 that would otherwise have stopped at a gap with Verdict: BLOCKED (planning of 2026-10-11): R-AZ-25 (05 section 5): the invocation token of command.invoked carries run_id only if the owner of the control command has an active run in the thread at execution time, otherwise the claim is missing (M2-16, M2-06); R-DM-42 (03 messages): messages.system_code also allows agent_session.resume_failed; if an adapter reports bind_failed, the server writes exactly one such system line with the agent of the run as author, and a system line does not reopen a done thread (M2-12, M1-56, 00 "Done", 01 6.1). R-AZ-26 (04 section 15.7 item 2, 05 section 5): for the autocomplete of an agent command, token and payload name the thread of the request or, in a text channel without a thread, the main timeline; in a forum channel without thread_id the server checks as for a new thread, does not ask the agent, issues no token and returns an empty list at once (M2-17); R-AZ-27 (05 section 4.11): the server writes the automatic state changes of outgoing webhook subscriptions as the system action webhook_out.transition into the audit log (M2-26); R-DM-43 (03 message_attachments, messages): message_attachments carries the copies thread_id and channel_id of its message (channel_id changed along on a move, without its own foreign key), messages the column has_attachments; M3-07 builds both, M3-14 filters with them before the candidate limit. R-DM-44 (03 runs, 04 section 15.3): runs.invocation_held_until remembers a held agent invocation, so that release and timeout write it exactly once (M3-07). R-AZ-28 (05 sections 2.6 and 4.12): new read action cost.reconcile.read on the instance for owners in a session or an app with scope costs:reconcile (without step-up, audit no, rl.read), operation listCostEstimates with the estimates per run in the mode organization without channel, thread and content (M2-28). R-AZ-29 (05 section 5 "Check without a request", 07): if the server checks the permissions of a human without their request (before every delivery to an agent, at run.token.renew, at a run from deferred follow-ups, before handing an app command or an interaction to an app, and for the permissions of the triggering human in SR9), the principal is this human without session, device and token, blocks come only from their status and the owner status from the current owner list; the permission state computed this way is never that of a personal token (M2-08, M2-14, M2-25). The affected briefs refer to the rule instead of a start check. |
09 section 10.4 and D-AG-12 send every control command with a token and without a run, 09 section 9.5 and 22 section 9 require exactly one system message with a code on fail open (R-DM-34); without the amendment the brief could only have ended with Verdict: BLOCKED according to P-11, although M2 must be buildable before the end of M1 (D-W-100). |
03, 05, 09, briefs/README.md |
| D-W-105 |
Terminal block after the briefs M2-43 to M2-47 (planning of 2026-10-11): M2-43 also depends on M1-67, because the control connection uses the frame codec, test client and routes of the gateway (M1-64), the short-lived path EphemeralPublisher (M1-66) and api/specschema (M1-67); M1-64 to M1-66 follow through M1-67. The row of M2-44 checks TM-T07 and TM-T12 each without the console part (M6-18). Only M2-44, where the deadline terminal.open_timeout comes from, registers and counts the metric bothy.terminal.relay.unpaired with the attribute bothy.terminal.side; M2-47 checks it with two nodes, M2-46 registers the other three metrics bothy.terminal.*. Every end of a terminal session reaches relay and control connection only through the observer of M2-44 at the log tail of every api process, because NOTIFY event_log runs in every mode and the tail reads the committed rows of event_log from the database; this applies to separate api and worker processes with cluster.mode = single as well as to the node of the connections with cluster.mode = multi. M2-46 keeps the dependency M2-45, M2-47 sends no signal of its own for session ends. Index, waves and graph are regenerated with tools/docs/brief_deps.py --write. |
Without M1-67, M2-43 would have built outside its dependencies and could only have ended with Verdict: BLOCKED; a metric with three owners would have been registered twice; a second path for session ends would have made two tickets depend on each other, while the tail already covers both modes (24 section 8.3 "immediately"). |
PLAN 8, 24, briefs/README.md |
| D-W-106 |
The column "Depends on" names every ticket that the start check of a brief requires (review of the briefs of 2026-10-11): M2-02 depends on M1-69 instead of M1-54 (canary scenario; above it M1-71, M1-65, M1-63 and M1-54); M2-13 on M2-11 and M2-12 instead of M2-09 (follow-ups and agent sessions belong in the context); M2-15 also on M1-57 and M1-58 (fetching, editing and deleting messages); M2-29 also on M2-14 (header Invocation-Token, SR9); M2-31 on M2-27 instead of M2-11 and M2-12 (above it M2-24 and everything else); M2-33 and M2-34 also on M2-12 (AgentSessionReport.started, bind_failed); M2-36 also on M1-72 (frame of make perf, above it M1-71); M3-02 also on M1-09 and M1-22 (secret provider, egress client); M3-03 on M1-69 and M1-70 instead of M1-55 (canary and leak tests). Because these edges imply others, M1-56 drops out at M2-04, M1-66 at M2-10 and M1-19 at M2-27; M2-03, M2-05, M2-11, M2-49, M2-50 and M3-11 get their missing tickets through the new edges. With the brief of M3-07, M3-14 depends on M1-57, M3-07 and M3-13 (M1-58, M1-69 and M1-70 through M3-07), M3-15 only on M3-14, and M3-07 also on M2-11, so that the follow-up run from deferred follow-ups already knows the hold for non-final attachments. Index, waves and graph are regenerated with tools/docs/brief_deps.py --write; M2-02 and with it the run chain start after M1-69. |
The launcher starts a brief as soon as every ticket of its line "Depends on" is merged (P-01, D-W-100); a start check on a ticket outside this closure would surely have ended the brief with Verdict: BLOCKED. The same class of error occurred in 16 briefs; the rule "start check needs" in tools/docs/brief_deps.py checks it since then for every brief (part of make docs). |
PLAN 8, briefs/README.md, tools/docs/brief_deps.py |
| D-W-107 |
Further edges after the start checks (review of the briefs of 2026-10-11, rule "start check needs" extended by the form BLOCKED <ticket> not merged; sentences with "only if merged" or "where merged" count as optional): M2-11 depends on M2-10 instead of M2-09 (acknowledgement of the follow-up as a run patch); M2-37 also on M2-22 (finishRun with costs in step 8); M2-46 also on M1-69 (canary scenario with terminal input and output); M2-48 on M1-30, M1-72, M1-73, M2-24, M2-46 and M2-47 (smoke test overlay, chaos frame of make perf, expiry job of the grants, app token for TM-T17); M1-71 also on M1-37, because harness.Start creates the first owner through the service of bothy setup bootstrap-owner and uses createUser with local_account (M1-71 was not built yet; no M1 wave moves); this drops M1-37 at M1-73 as an implied edge. Index, waves and graph are regenerated with tools/docs/brief_deps.py --write. |
As in D-W-106: without the edge the launcher would have started the brief before the required ticket, and it could only have ended with Verdict: BLOCKED; three M2 briefs (M2-31, M2-36, M2-48) reported M1-37 at M1-71 independently. |
PLAN 7, PLAN 8, briefs/README.md, tools/docs/brief_deps.py |
| D-W-108 |
Planning of the M3 push block (2026-10-11): (1) M3-19 creates the table push_registrations (03), the read port of the active registrations and the job notify.push_send with a provider port; M3-20 (APNs) and M3-21 (FCM) implement that port; M3-22 adds registerPush, unregisterPush, the preview token and the preview endpoint on that table and the deactivation on logout, device revocation and user disable. (2) Core amendment R-AZ-30 (05 section 4.9 row push.deliver, section 6 row notification.created): hints of the kinds security and admin have no thread and use the recipient rule of 13 section 2 at the time of each decision and send instead of push.deliver. (3) Core amendments R-DM-45 (03 6.5, 04 2.2 rule 5: deleting a file redacts its file.updated events to IDs and states) and R-DM-46 (03 1 item 7: files.purge_objects removes the row of a deleted file after its objects), found while writing M3-08. (5) Core amendments R-DM-47 (03 runs.invocation_held_until also for a pending server transcript) and R-DM-48 (03 files: derived ETag of a transcript), found while writing M3-09; M3-09 depends on M1-47, M2-31 (which implies M2-09, M2-13 and M2-20) and M3-05 as well and closes the deferred conformance lines AG-20 attachment M3-07, AG-20 transcript M3-09 and AG-25 voice M3-09. (6) Core amendment R-AZ-31 (05 sections 5 and 6, openapi.yaml, asyncapi.yaml): the durable personal event device.pending is the source of hints of kind security, written by identity when a device registers as pending; push.deliver and the recipient rule of R-AZ-30 are checks without a request (R-AZ-29); M3-19 builds the writer and the mapping and depends on M1-38 as well; M3-19 also builds the badge count and the badge sync of 13 section 8, which no plan row named; M7-06 emits the reminder of a privacy request deadline for hints of kind admin; found while writing M3-19. (8) notification.created of a hint without a thread has the recipient as resource (05 section 6, R-AZ-30); M3-23 delivers desktop hints in role all, M7-17 (now also after M3-23) adds the shared presence state and the worker-to-api path for separate roles and several api nodes; M3-22 (now also after M3-23) fills the preview fields of notification.created (13 6.3) with its preview builder. M1-66 at M3-23 and M2-09, M2-13, M2-20 at M3-09 drop out as implied edges. (9) From the review of the briefs: M2-46 depends on M1-30 as well, because its revocation test ends sessions through the job authz.expiry_sweep. M1-30 at M2-48 drops out as implied. (7) M3-22 depends on M3-06 and M3-07 as well, so the full preview of 13 6.4 (attachment_count, image_url) is always built; 08 10 step 4 sends no revoked push, because step 2 disables the device's registrations in the same transaction. (4) M3-16 and M3-24 depend on M1-72 as well and its plan row names perf/search with the make target bench-search instead of tools/bench-search, because the load test extends the perf frame of M1-72 and 02 section 6 puts load tests under services/server/perf/. PLAN rows M3-16, M3-19 and M3-22 name these changes. |
(1) Before, only M3-22 created push_registrations, but M3-22 depends on M3-20 and M3-21, which depend on M3-19, and M3-19 must create one notify.push_send per active registration (13 section 12 point 5) and test bundling and restarts with registrations: the order was wrong (found while writing M3-17). (2) Without R-AZ-30 no check is defined for two of the hint kinds of 13 section 2, and the push tickets could only stop with Verdict: BLOCKED. (3) Without them a deleted file would keep its name and digest in the event log and in its row, against 20 4.2, 4.3 and DP-T01. (4) As in D-W-106: without the edge the launcher would start M3-16 before its frame exists. (5) Without R-DM-47 the hold of a voice invocation contradicts its column; without R-DM-48 If-Match has no value to compare; without the edges M3-09 could build its switch, the transcript in the run context, the adapter report, the transcript read and the conformance cases only where merged, and no ticket would close the deferred lines. (6) Without the event the kind security of 13 section 2 had no source in the event log, and without the addition to section 5 no principal is defined for the hint check of a person who sent no request. (7) The preview fields need attachments and thumbnails; a revoked push had no kind, no recipient rule and no registration left to use. (8) The envelope of such a hint had no resource; the presence of 13 12 point 4 lives in the api process while the consumer runs in the worker; the preview builder belongs to M3-22, which ran before M3-23 in the old order. (9) As in D-W-107 for M2-48. |
PLAN 9, 05, 13 |
| D-W-109 |
Rule for names in hosts.reported_doctor: If a person with terminal access, also a former one, can cause a violation, the message to the server names the account in which the instance operator must look, each account with its own entry (session_user); a new check follows the same rule. These are HD6 and HD11 (as before), new: HD10 with a wrong peer (the session user of this channel) and HD9 for every former session user (name bound to a person in the state directory, no longer in host_service.session_users) whose home can be listed; the console also shows the person of its assignment. Names of other users of the host are named only by the output on the host; for them an entry without a name goes to the server. HD9 stays at "Start refused". After bothy-host session-user remove the instance operator removes the long-running processes of the user, ends its processes and deletes it together with its home (or first moves the home to a location only for administrators); locking alone is not enough. After the access removal of a person who is not an owner, the console advises this step. |
remove closes the home but ends no processes. A long-running process that the person set up as the session user reopens the home, because it still belongs to their user; HD9 then refused the start of the whole host, and the console got only the code. The same applied to HD10 when a process of a person takes the place of their helper. This was the same error as with HD6 before D-W-64: a person brings the host to a standstill, and the console does not name them. Instead of extending every check one by one, one rule applies to every check that a person can make fail; the server knows these names from the assignment and needs no other names. Locking ends no running process, and passwd -l under Linux locks only the password ("Note that this does not disable the account", https://manpages.debian.org/bookworm/passwd/passwd.1.en.html, retrieved 2026-10-11 [verified]). Only deleting takes the home out of HD9 and ends the path; otherwise a later user with the same user ID would get the home without a user. Limit: after the loss of the state directory, the name of a former session user is missing from the message. |
24 2.2 items 3 and 4, HD9, HD10, "Reason for HD9" and rule for names, 24 6.5, 24 10.1, R-TM-30, 16 4.4 items 1 and 6, 03 hosts, openapi.yaml HostDoctorCheck, 01 A8, 20, ADR 0068, Threat model T-TM-04, T-TM-11, T-TM-12, T-TM-14, RISKS RK-26, RK-89, TM-T24, TM-T26, PLAN M2-42, M4-37 |
| D-W-110 |
check_runners.py recognizes release jobs independently of the spelling: it compares environment names without case, and blanks at the edges do not count. New rule environment-not-literal: an environment name with ${{ (as text or in environment.name) is a finding in every workflow. Not covered is where the secrets lie in GitHub: signing secrets exist only as secrets of the environments release* (ADR 0039, rule 4). The planning branch also compared runner labels without case and required bothy-ci or bothy-release in every self-hosted switch; on main there are no self-hosted runners (D-W-92), and a label in another spelling is already a finding of runner-not-github-hosted. |
GitHub treats environment names without case ("Environment names are not case sensitive", https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments, retrieved 2026-10-11) [verified]. With the literal comparison, a job with environment: Release got the signing secrets and passed outputs of resolve to a shell, without a finding. GitHub allows an expression as the name ("The value of name can be an expression", https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, section jobs.<job_id>.environment, retrieved 2026-10-11) [verified]; whether it yields an environment release* would only be known at run time, and no workflow file needs it. These rules had already missed two forms (D-W-61, D-W-72); so the check now compares like GitHub itself instead of adding single spellings. |
tools/ci/check_runners.py, tools/ci/tests/test_check_runners.py, ADR 0039, gates.md, Threat model T-SC-05, R-QA-12, D-W-61 |
| D-W-111 |
New rule actions-write in tools/ci/check_runners.py: no workflow has actions: write, neither in permissions on top level nor on a job, also not on a job with uses; write-all is now a finding on a job too (broad-permissions). There is no exception, also not for release.yml; if a workflow needs the permission later, that is a decision of its own. Not covered is a token from a secret with permission on Actions. |
The rule reusable-release-caller (D-W-73) saw only calls by uses:. A pull request workflow with actions: write could start portability.yml with gh workflow run: events created with the GITHUB_TOKEN start no run otherwise, but "workflow_dispatch and repository_dispatch events always create workflow runs" (https://docs.github.com/en/actions/concepts/security/github_token, retrieved 2026-10-11) [verified], and the endpoint POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches requires the permission "Actions" with write for the installation token of a GitHub App, which the GITHUB_TOKEN is (https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps, retrieved 2026-10-11) [verified]. The release workflow would then run outside the one run per tag of its release. Blocking the permission hits every path to a workflow_dispatch (gh, REST API, an action) in one place, instead of searching for commands in steps, and no workflow needs it. release.yml neither: with the permission it could start portability.yml a second time next to the call. A repository_dispatch is already allowed by contents: write; it starts only workflows that name this trigger (https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, retrieved 2026-10-11) [verified], which no release workflow may name (rule release-trigger). On the planning branch the reason was also the minutes budget of the private phase, which main does not have (D-W-92). |
tools/ci/check_runners.py, tools/ci/tests/test_check_runners.py, ADR 0039, gates.md, 23 4, D-W-73 |
| D-W-112 |
Port of the planning branch to main (2026-10-12): the planning rounds after the public baseline come to main in English with their IDs, as several pull requests. Three IDs of the planning branch are taken on main by other rulings (D-W-92, D-W-93, D-W-94 since the public baseline and the translation); their rulings carry new IDs here: D-W-92 of the planning branch is D-W-109, D-W-93 is D-W-110, D-W-94 is D-W-111, and every mention follows. Parts that rest on the self-hosted runners and the minutes budget of the private phase (docs/quality/self-hosted-runner.md, docs/quality/ci-budget.md, rule release-runner-label) are not ported, because main has only GitHub-hosted runners (D-W-92); D-W-110 and D-W-111 say what remains. Current state: in 05 section 5, the sentence on the actions that the principal of a check without a request may use also names push.deliver, which the planning branch lists among these checks (R-AZ-31) but left out of that sentence. |
The public baseline was built from an earlier state of the planning branch than its later rounds and reused D-W-92 and D-W-93; the translation added D-W-94. Renumbering the three later rulings keeps every ID unique (register_ids.py) and leaves the rulings that main already cites unchanged. |
D-W-92, D-W-93, D-W-94, D-W-109, D-W-110, D-W-111 |
| D-W-113 |
tools/docs/brief_deps.py fails when a brief calls a ticket of its own dependency closure missing or optional. The check reads each paragraph and list item on its own, so a list never reaches into the next block. Parenthesized text is matched as a segment of its own and dropped from the sentence around it, so an aside like "M1-04 (which builds on M1-02)" names only M1-04 and a comma inside it ends nothing. Recognized forms: a ticket directly before "is not in" or "lies outside" a closure, a list of tickets ("M1-01, M1-02 and M1-04") before "are not in" or "lie outside" it; a ticket after "if", "when" or "once" with "is merged", "has (already) been merged" or "is on the base", a list with "are" or "have"; a ticket directly before "only if merged" or "only where merged"; a ticket followed by "(if merged)" with no other ticket in between; and the tickets of a list after "not in the closure ...:", "outside ... closure:" or "if merged:" up to a comma that starts a new clause (a comma before "and" or "or" and one last ticket does not). Not covered: a sentence that names the ticket only by a path, a feature or a pronoun ("the registry of M1-68 when it is on the base", "if services/server/test/canary is on the base"), other verbs and word orders ("unless", "is not merged yet", "(if present)"), a subject whose list words are joined by other text, and a list after such a colon whose tickets stand in list items of their own; review catches those. A correct sentence can still fail when it puts a dependency in one of these forms with another meaning; such a sentence is reworded. |
The port of the M2 briefs found the same class twice: sentences in M2-02, M2-03, M2-05, M2-08, M2-10, M2-11, M2-12, M2-16 and M2-22 (and in M1-26 and M1-59) still called tickets missing or optional that are dependencies, and a build session would have skipped work or reported a plan gap that does not exist. The launcher starts a brief only after its closure is merged, so such a sentence is always wrong; one check holds the common forms of the class instead of fixing instances (D-W-106 added the opposite direction for start checks). |
tools/docs/brief_deps.py, tools/docs/tests/test_brief_deps.py, tools/README.md, briefs/README.md, D-W-106, D-W-107 |
| D-W-114 |
Decision of the owner of 2026-10-13: D-W-100 extends to milestone M4. The briefs of M4 are written before M1 is closed, in the format of the M2 and M3 briefs, in English and in the same brief index with waves across all milestones; each starts as soon as every ticket of its line "Depends on" is merged. They name interfaces from M1 to M3 as the briefs and the merged code name them and carry the same limit under "Boundaries" (follow the merged code where it differs only in names, stop with Verdict: BLOCKED where it differs in behaviour from the specification). The column "Depends on" of nine M4 tickets changes with the briefs: M4-03 names none (M0 closed) instead of M0-01, because M0 tickets are not in the index and M0 closes before any brief starts; M4-17 depends on M4-14, M4-15 and M4-16, because its scenarios from 04 section 16 send through the outbox and apply run documents, and its fake server lives in packages/sync_engine (library testing.dart) so that the headless sync tests and the app share it; M4-18 depends on M4-07 and M4-17 instead of M4-14, because its integration tests run against that fake server; M4-20 then depends only on M4-19 and M4-21 only on M4-20 and M2-31 (M4-15 and M4-16 follow from M4-17); M4-31 also depends on M1-69, because it builds the server endpoint client_metrics.submit on the signal catalogue of M1-68, the setting privacy.client_metrics of M1-61 and the canary harness of M1-69; M4-34 also depends on M1-73 (Compose smoke stack for the desktop E2E); M4-35 drops M2-38, which follows from M2-44; M4-36 depends on M4-35, M4-24 and M2-48 instead of M4-14 (implied by M4-24), because its integration tests run against the host service container and test overlay of M2-48. |
Builds must not run out of work while M1 closes (owner, 2026-10-13: no idle capacity); the index lists only the transitive reduction, and tools/docs/brief_deps.py requires plan and index to agree. A brief whose tests need a ticket outside its closure could only stop at its start. |
briefs/README.md, PLAN 10, D-W-100, D-W-102, D-W-106 |
| D-W-115 |
The apps read eight configuration keys that steer their own behaviour but have place Server in the configuration reference, because no API operation delivers them: sync.reconnect_backoff_base, sync.reconnect_backoff_cap, sync.typing_min_interval, sync.typing_ttl, sync.max_focused_threads, client.cache_max_age, client.outbox_retry_base and client.outbox_retry_cap. AppConfig of packages/domain (M4-08) carries them with the default of the reference next to the keys of place App, and a test fails when one of them leaves the reference or changes its place. The server stays authoritative where it checks a value itself (for example a focus with more than sync.max_focused_threads threads is rejected). Limits that getInstance delivers in InstanceLimits (file_max_upload_bytes, voice_max_duration_ms, the message limits) are read from the server and never from AppConfig. |
M4-02, M4-09, M4-12 to M4-15 need these values; generating AppConfig only from place App would leave them without a source, and a literal in code would break the rule that limits are named keys. An API field for them can replace this list later without changing the callers. |
briefs/M4-08-domain.md, operations/configuration.md, D-W-114 |
| D-W-116 |
The briefs of M4-11 to M4-38 change the column "Depends on" of nine M4 tickets: M4-15 also depends on M4-02, because its executors call the generated API and classify ApiProblem; M4-18 also depends on M4-11 (and through it M4-10), because the app shell registers the device keys of macOS and Windows; M4-24 also depends on M4-21, because its shortcuts stop runs, toggle the live box and are tested against approval cards (R-PR-04); M4-28 depends on M4-27 instead of M4-26, because the guided flow of 10 11 starts in the grant wizard; M4-30 depends on M4-25 instead of M4-18, because the notification note and "Start at login" live in the settings; M4-31 depends on M4-25 instead of M4-18, because it makes the crash report switch of the settings visible; M4-32 depends on M4-22, M4-24, M4-28, M4-29 and M4-33 instead of M4-21, so that its checklist covers the chat, settings and administration screens A1 to A6 and, through M4-33, the notification area of M4-30 (M4-36 adds the terminal rows and M4-37 the rows of screen A8, each into its section of the skeleton page docs/quality/accessibility-checklist.md that the brief package creates, so no build ticket has to create the page or its navigation entry); M4-33 depends on M4-20 and M4-30 instead of M4-19 and M3-18 (which M4-30 implies through M3-23), because the thread menu sits in the thread header and its test needs the notification stream and the fake's notification decision of M4-30 (and through it the settings entry and the step-up dialog of M4-25); M4-34 drops M4-21 and M4-30, which M4-24 and M4-33 now imply. AppConfig also carries auth.device_pending_ttl and, added by M4-36, terminal.max_session_duration with the rule of D-W-115, because the Device and TerminalSession schemas name no expiry and screens E4 and D11 show the remaining time (display only; the server decides). The cost figures of screen A5 come from getCostStats (M6-05) and join the agent page of admin_ui with the statistics views of M6; M4-28 builds the backend switch, the guided flow and the agent tokens. |
A brief whose tests or screens need a ticket outside its closure could start before that ticket is merged and could only stop; The cost figures belong with the statistics of M6 (M6-05 and the views built on it), so M4-28 does not wait for them. |
briefs/README.md, PLAN 10, D-W-114 |
| D-W-120 |
Decision of the owner of 2026-10-13: D-W-100 extends to milestones M5 and M6 ("no idle capacity": briefs of later milestones are written now). The briefs of M5 and M6 are written before M1 is closed, in the format of the M2 to M4 briefs, in English and in the same brief index with waves across all milestones; each starts as soon as every ticket of its line "Depends on" is merged. They name interfaces of earlier milestones as those briefs and the merged code name them and carry the same limit under "Boundaries" (follow merged code that differs only in names, stop with Verdict: BLOCKED where it behaves differently from the specification). They land in packages; each package records its changes of the column "Depends on" in its own ruling. This first package (M6-01, M6-05, M6-10 to M6-15) changes: M6-01 depends on M1-39, M1-40, M1-41, M1-43, M1-45, M1-49 and M1-64 instead of M1-40 and M1-41, because the console form covers the password, passkey, OIDC and proxy header logins, revocation ends console sessions, the stored view builds on viewAsUser and the Origin check of the gateway upgrade (ID-T19) is built by M1-64; M6-05 depends on M2-22, M2-26 and M3-22, because the error rates read webhook deliveries and disabled push registrations; M6-11 also depends on M1-04, because it adds checks to make ops-check; M6-13 also depends on M2-15, because approval cards are one of its measuring points; M6-15 depends only on M6-13, because M1-65 follows from it. The core contract gets, additively, R-DM-49 (auth_sessions.view_as_user_id and view_as_started_at for the console) and R-AZ-32 (action access.view_as.end, owned by identity; while a view is stored, access.view_as.end, auth.logout, auth.step_up and self.read on one's own session are decided without view_as), which 07 9.2 asks to add before M6-01 builds the console session. |
Builds must not run out of work while M1 closes (owner, 2026-10-13); a brief whose tests need a ticket outside its closure could only stop at its start, and a brief that needs a core contract rule the contract lacks could only end with Verdict: BLOCKED (P-11, D-W-104). |
briefs/README.md, PLAN 11, PLAN 12, 03, 05, D-W-100, D-W-104 |
| D-W-121 |
The briefs M5-01 to M5-07 (second package of D-W-120) change the column "Depends on" of the plan: M5-02 also depends on M4-23, because the tabs Mentions and Running and the search of screen M1 reuse the cross-cutting views of M4-23; M5-03 depends on M5-01 and M5-02 instead of M4-21 and M5-01, because the Android app reuses the phone layouts of M5-02 instead of building a second set of screens (M4-21 follows from M5-02); M5-04 depends on M3-22, M4-31 and M5-02 instead of M3-22 and M4-09, because the Notification Service Extension needs the iOS runner and build configuration of M5-02 (M4-09 follows from it) and forwards its display counters through the client metrics of M4-31; M5-05 depends on M5-03 and M5-04 instead of M3-22 and M5-03, because the Android service reuses the native strings generator, the DPoP contract vector and the app-side push rules of M5-04 (M3-22 follows from it); M5-06 depends on M1-73, M4-22, M5-03 and M5-04 instead of M5-02, M5-03 and M3-03, because the Share Extension sends through ClientKit of M5-04, the app takes shared files over into the upload entries of the outbox of M4-22, searches targets with searchThreadTitles of M3-14 (which follows from M5-02 through M4-23), and proves ID-T16 against the Compose smoke stack of M1-73 (M3-03 follows from M5-04); M5-07 depends on M3-09, M4-22 and M5-03 instead of M5-02 and M3-09, because recording uses the upload entries and the voice player of M4-22 and runs on both phone platforms. Tests that need a server stack (the Compose stack, a host service container) live outside apps/client/integration_test/, because the platform jobs of M4-01 run that folder without a server; M4-34 and M4-36 do the same on the desktop. No check enforces this (brief_deps.py does not read test paths); the brief review does. |
A brief whose tests need a ticket outside its closure could only stop at its start (D-W-106); reusing the phone layouts and ClientKit keeps each feature built once (owner rule 2, R-CL-01). |
PLAN 11, briefs/README.md, D-W-120 |
| D-W-122 |
The briefs M5-08 to M5-13 (third package of D-W-120) change the column "Depends on" of the plan: M5-08 depends on M5-03 instead of M5-02, because the app lock runs on both phone platforms and uses the recent apps protection of M5-03; M5-09 depends on M4-27 and M5-03 instead of M4-26 and M5-02, because revoking a grant reuses the grant controllers of M4-27 and the actions run on both phone platforms; M5-10 depends on M5-06, M5-07, M5-08 and M5-09 instead of M5-02 and M5-03, because its checklist covers the share sheet, the recording, the lock and the emergency actions; M5-11 depends on M1-39, M5-05, M5-06, M5-07, M5-08 and M5-09 (M5-04 follows from M5-06), because its seed creates local accounts with TOTP and E2 logs in with them; M5-12 depends on M4-36 and M5-10 instead of M4-36, M5-02 and M5-03, because the terminal detaches on the lock event of M5-08 (which follows from M5-10) and reattaches only after the app lock (PLAN 11 red team), and its new routes M8 and M9 need rows in the checklist of M5-10, whose route test fails otherwise; M5-13 depends on M4-38, M5-11 and M5-12, because it measures the terminal targets with the output generator and the timing hook of M4-38 and completes the checklist of M5-10 (which follows from M5-12) for M8 and M9. The mobile E2E suites, the measurement and the phone terminal stack test live outside apps/client/integration_test/, as the desktop ones of M4-34 and M4-36 do, because the platform jobs run that folder without a server. M5-11 creates the make target e2e-mobile, outside check. Profile builds run only on physical devices, so the mobile targets client.perf.* (including the two terminal targets of 15 19, which M5-13 measures) are measured by M5-11 and M5-13 where a physical reference device is attached and otherwise checked at the closing of M5 (PLAN 4 item 3); an unattended build without such a device reports them as not proven. These changes build on D-W-121. |
A brief whose tests need a ticket outside its closure could only stop at its start (D-W-106); the lock must exist before the terminal can rely on it. |
PLAN 11, briefs/README.md, D-W-120 |
| D-W-123 |
The briefs M6-02 to M6-04, M6-06 to M6-09 and M6-16 to M6-18 (fourth package of D-W-120) change the column "Depends on" of the plan: M6-02 also depends on M4-09, because the console builds on the generated api_client and the LoginService of auth; M6-03 also depends on M1-73, because its headless test runs the console against the Compose smoke stack and creates the make target console-e2e; M6-04 depends on M4-28, M4-29 and M6-03 (M4-27 follows from M4-28 and M6-02 from M6-03), because its patrol web tests run in console-e2e; M6-06 depends on M4-26, M6-02, M6-05 and M6-11, because A7 sits in the desktop administration of M4-26 and is drawn with the marks of M6-11; M6-07 depends on M1-30 and M6-04, because the expiry overview builds on the expiry of M1-30; M6-09 depends on M1-47, M3-10 and M6-04 as planned; M6-16 depends on M5-03, M6-04, M6-06, M6-12, M6-14 and M6-15 instead of M5-02, because M10 runs on iPhone and Android and the console suite runs among the patrol web suites that M6-04 adds to console-e2e (M6-03 follows from M6-04); M6-17 depends on M6-16 (M6-03 follows); M6-18 depends on M2-48, M4-35, M4-37 and M6-04 (M6-03 follows), because its suites need the host service container of M2-48. The core contract gets, additively, R-AZ-33 (public action console.csp_report for the report-to endpoint of 19 5) and R-AZ-34 (read action access.hints.read for the lists of 07 9.4 and 9.5). |
A brief whose tests need a ticket or a make target outside its closure could only stop at its start (D-W-106); an operation without an action from the catalogue cannot be built (05 section 1, P-11). |
PLAN 12, 05, briefs/README.md, D-W-120 |
| D-W-124 |
Decision of the owner of 2026-10-14: D-W-100 extends to milestone M8 ("no idle capacity"). The briefs of M8 are written before M1 is closed, in the format of the M2 to M6 briefs, in English and in the same brief index with waves across all milestones; each starts as soon as every ticket of its line "Depends on" is merged, names interfaces of earlier milestones as their briefs and the merged code name them, and carries the same limit under "Boundaries". Every M8 brief tests only against fixtures and fakes and never contacts a live Discord account or server; test_brief_format.py requires the boundary item "Never contact Discord". No brief needs a paid plan or paid service: the import uses a free Discord bot application. This first package (M8-01 to M8-08, M8-10, M8-11) rules: (1) The tool code lives under services/server/cmd/bothy/importtool/ (the plan's cli/import; bothy import is a subcommand of the one binary, D-L1, component cmd; import is a Go keyword). (2) Chapter 22 is aligned with 08 6.4: a cli session never provides step-up, so every import.manage step (create with the mapping, start, pause, cancel, complete, ownership list) and channel.operator.set run in the web console (M8-08); the tool uses import.read, import.apply and read actions; --apply imports into the running source of its guild; bothy import login|logout (device pairing, 02 3.3, 22 10.1) is new; the dry run may call refreshToken and rewrite only the session file (22 10.2 rule 5); a run lease in the cursor replaces "source already running" (22 7.3, 7.4); switch performs the last mirror round and prints the console steps, revert only reads and prints (22 13.3, 13.4); forum_tag gets its origin key (22 7.1); ImportSource returns the stored cursor, so a new run reads its progress and lease (M8-03); the bytes of imported files go through two new operations under import.apply (uploadImportFilePart, completeImportFile), because uploadPart and completeUpload are file.upload, which rule 1.8 refuses in a read-only mirror channel, and ImportFileCreate carries sha256 for file_upload_sessions.declared_sha256 (M8-11); completion counts only imported channels, and the web console warns before it (22 13.5). (3) Two tickets are added: M8-10 (login and generated API client of the tool, split from M8-04) and M8-11 (thread, message, pin and file items, split from M8-03). (4) The column "Depends on" changes: M8-01 depends on M1-22 instead of M1-05, because every connection goes through the egress client; M8-03 depends on M2-09 instead of M3-07, because it builds the structure items and the read-only switch that cancels runs through requestCancel, while the content items that need files move to M8-11; M8-04 depends on M8-02, M8-10 and M8-11 instead of M8-02 and M8-03; M8-06 depends on M2-22, M2-34 and M8-04 instead of M8-03 and M2-12, because it adds the --sessions path of the tool and the cost baseline that the Claude Code runner must start from (M2-12 follows); M8-10 depends on M1-38 and M1-71 (pairing, real-server harness); M8-11 depends on M1-59, M2-18, M3-07, M3-13 and M8-03 (pins, the placeholder title rule, attachments, the search language of every message insert). M8-09 keeps its row and follows in a later package once M7-14 and M7-21 have briefs. |
Builds must not run out of work while M1 closes (owner, 2026-10-14); a brief whose tests need a ticket outside its closure could only stop at its start (D-W-106), and a brief that relied on a step-up from the command line could only end with Verdict: BLOCKED, because 08 6.4 forbids it (R-ID-17). |
briefs/README.md, PLAN 14, 22, 08 6.4, 02 3.3, D-W-100, D-W-120 |
| D-W-125 |
Decision of the owner of 2026-10-14: D-W-100 extends to milestone M7 ("no idle capacity": briefs of later milestones are written now). The briefs of M7 are written before M1 is closed, in the format of the M2 to M6 briefs, in English and in the same brief index with waves across all milestones; each starts as soon as every ticket of its line "Depends on" is merged, and each carries the limit of D-W-100 under "Boundaries". Two further owner rules apply to every M7 brief. First, no costs: no brief requires a paid plan or a paid service; where the plan names one (Apple Developer Program, a Windows signing certificate or Artifact Signing, a Play Console account, cloud accounts, a Grafana Cloud account), the brief builds and tests a free path (test publisher, local VM, local registry, unsigned plugin allowed by its ID) and the paid step stays an owner decision (OQ-08, OQ-09, ruling T11); GitHub-hosted standard runners count as free because the repository is public. Second, no brief tags, releases, publishes or deploys by itself: release chains are proven with trial releases against local targets, and the first real tag of a component stays with the owner. The packages record their changes of the column "Depends on" in their own rulings; this first package (M7-01 to M7-09, M7-22) changes: M7-01 also depends on M1-08, because it adds a secret generator to the configuration reference that make config-doc-check holds; M7-02 depends on M3-02, M3-03 and M7-01, because the object backup reads files through the fs and s3 drivers and the manifest counts files; M7-04 depends on M2-07, M2-26 and M7-02 instead of M7-02 and M1-65, because restore finalize moves the cursors of agent consumers and webhook subscriptions and webhooks carry sync_epoch (M1-65 follows from M2-07); M7-05 depends on M1-39, M1-40, M1-59, M2-15, M2-22, M2-26, M2-46, M3-08, M3-15 and M3-22 instead of M3-08, because its participants cover the tables of the classes security, content, transient and cost that those tickets create (local credentials, passkeys, reactions and pins, interactions, costs, webhook deliveries, terminal sessions, text extracts, push registrations); M7-08 depends only on M7-09 (M2-01 follows); M7-09 depends on M6-13, M7-02 and M7-05 instead of M2-20, M3-20 and M1-68 (which follow), because its checks read the own measurement in stats_minutes and it builds retention.ops for the status tables and backup_runs on the participant port of M7-05. The core contract gets, additively, R-DM-50 (instance.pg_system_identifier and instance.pg_timeline_id, the start lock that ADR 0059 left open and M7-04 builds) and R-AZ-35 (the personal durable event gdpr.request_due with its schemas in openapi.yaml and asyncapi.yaml, which D-W-108 assigns to M7-06), and the consumer name privacy.journal in the check of event_consumer_cursors.name, which M7-07 adds by an additive migration (R-DM-27), and R-DM-52, the table deletion_journal_state (module privacy) that numbers the objects of the deletion journal densely, because the object storage port has no listing and no conditional write. M7-22 allows the AWS SDK in a second package, internal/audit/adapters/s3sink (vendor awssdk for the module audit in modules.yaml, narrowed by depguard as for M3-02), with its own narrow interface (conditional PutObject, GetObject, ListObjectVersions, GetObjectLockConfiguration), because the checkpoint sink needs listing and the lock check, which the core S3 rules of the file driver (ADR 0010, ADR 0048) exclude for files; the file driver keeps its rules. |
Builds must not run out of work while M1 closes (owner, 2026-10-14); the owner excludes all costs and keeps tags, releases and deployments for himself; a brief whose tests need a ticket outside its closure or a rule the core contract lacks could only stop at its start (D-W-106, P-11). |
briefs/README.md, PLAN 13, 03, 05, D-W-100, D-W-108 |
| D-W-126 |
The brief of M8-09 (second package of D-W-124) changes the column "Depends on" of the plan: M8-09 depends on M8-07 and M8-08 instead of M8-07, M8-08, M7-14, M7-21 and M5-11. The brief proves the migration flow of 22 13 on fixtures and fakes (a rehearsal over a whole fake Discord server against the real server), writes the generic, white-label guide docs/operations/discord-migration.md and ends with the owner's decision sheet; it never runs the live migration, which uses a real Discord bot and a real installation and needs the owner's go. M7-14 (portability of the release), M7-21 (status page) and M5-11 (mobile E2E) are prerequisites of that live run, which the guide names; PLAN 14 exit criterion 2 is met only by the live run. |
A brief whose dependencies it does not use would wait for nothing (D-W-106); the live run is an external step that only the owner starts. |
PLAN 14, briefs/README.md, D-W-124 |
| D-W-127 |
The release briefs M7-10 to M7-16, M7-23 and M7-29 (second package of D-W-125) prove their chains without a tag, a publication or a paid account. Trial release: make release-trial COMPONENT=<component> (M7-10 creates it for server; M7-29 adds bothy, M7-23 host) runs the release targets of the component on the build host against throwaway local targets: a registry container pinned by digest on 127.0.0.1, a cosign key pair generated for the run and signing with the signing config of the private phase (ADR 0062), Syft SBOMs and a signed SHA256SUMS, then the verification an instance operator runs (cosign verify --key, cosign verify-blob, sha256sum --check); it refuses to run when GITHUB_ACTIONS is set, and so do its trial-only variables (RELEASE_TRIAL_REGISTRY of M7-29 for the registry in the bundle, the trial key of make portability-trial of M7-14). This replaces the "trial release on a test repository" of RK-57, because creating remote repositories is outside every brief. Test publisher: on macOS a self-signed code signing identity in a temporary keychain (codesign --verify --strict must pass; spctl --assess accepts only an Apple-issued Developer ID with notarization, so the trial records its rejection and the release job runs it with the owner's identity), on Windows a self-signed certificate whose subject equals the MSIX publisher, imported only into the run's stores (signtool verify /pa must pass), a throwaway EdDSA key for Sparkle and a throwaway Android upload key; notarization, TestFlight and Play uploads need the owner's accounts (OQ-08) and run only in the release environments with his secrets. The keyless path of the public phase and the attestations can only run in GitHub Actions with a real tag; they stay not proven until the owner's first release. Changes of the column "Depends on": M7-11 also depends on M1-03, because the gate joins make api-checks; M7-13 depends on M5-11 and M7-12 instead of M5-11 and M7-10, because the update path comes from distribution.yaml and the test publisher of M7-12 (M7-10 follows); M7-14 depends on M2-29, M7-03, M7-09 and M7-29 (M7-10 follows from M7-29, M3-04 and M3-14 from M7-09), because its smoke run starts a test adapter from the test double of the Python SDK and calls the operations search (M3-14) and getStatus (M7-09) of 16 11.1; M7-15 depends on M7-29 instead of M7-10, because the host setup runs install.sh --bundle and the VM test installs the product trial, and that VM test (make tofu-vm-test) runs only on the build host, because the trial refuses to run under GITHUB_ACTIONS; M7-23 depends on M2-48 and M7-12 (M7-10 follows from M7-12). |
No brief may tag, release, publish or deploy, and none may require a paid account (D-W-125); a chain that only runs at the first real tag would be proven too late (RK-57). A brief whose tests need a ticket outside its closure could only stop at its start (D-W-106). |
PLAN 13, ADR 0062, OPEN-QUESTIONS, D-W-125 |
| D-W-128 |
The briefs M7-17 to M7-21 and M7-24 to M7-28 (third package of D-W-125) change the column "Depends on" of the plan: M7-17 depends on M1-72, M2-10, M2-47 and M3-23, because its cross-node test is a target of make perf (M1-72) and reuses the two-node harness of the terminal relay (M2-47); M7-20 depends on M1-73 and M2-27 (M1-04 follows from M1-73), because its end-to-end test starts the Compose stack with the profile observability; the owner creates the alert channel, its app and the incoming webhook in a session with step-up, because webhook_in.create and app.install need one and an API token cannot step up (07 SR5, 08 12), and the M7-20 tool only stores the URL; M7-21 depends on M5-03, M6-04 and M7-08 instead of M7-09, M6-02 and M5-02, because the status views run on iPhone and Android, the console suite joins the patrol web suites of M6-04 in console-e2e, and the maintenance mode of M7-08 is part of what the views show (M7-09 follows); M7-26 depends on M7-14 and M7-25 (M7-10 follows from M7-14); M7-27 depends on M2-48, M6-14, M6-15 and M7-14 (M2-29 follows from M7-14), because its integration test reads getStatsTimeline, getStatsSlo and getStatsBackends of M6-14; bothy setup demo is a client of the public API that bootstraps the owner, enrolls a demo device and steps up with its key, because owner actions need a person's session with step-up (07 SR5) and no system action covers the demo; M7-28 depends on M5-13, M6-16, M6-18, M7-25 and M7-27 (M4-38 follows from M5-13 and M7-29 from M7-27); its images come from the build host only, because GitHub-hosted macOS runners have no Docker engine and Windows runners no Linux containers (M4-34), so images of the Windows app follow on a host of the owner and stay an owner decision. The plan's column "Tickets that can start after M1" for M7 is recomputed from the dependencies of all three packages. The core contract gets, additively, R-DM-51: the table gateway_presence (module gateway, UNLOGGED, section 4.16 of 03) with the shared presence state that M3-23 left to M7-17 and that D-W-108 and RK-37 name without a mechanism; the module register, 02, the ERD and the module README list it. |
A brief whose tests need a ticket outside its closure could only stop at its start (D-W-106); without a table the worker cannot see the live connections of other processes, and a build ticket may not add one to the core contract (P-11). |
PLAN 13, 03, briefs/README.md, D-W-108, D-W-125 |