ctrlrelay 0.26.0__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CHANGELOG.md +12 -1
  2. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/configuration.md +12 -8
  4. ctrlrelay-0.27.0/docs/control-plane-brain.md +350 -0
  5. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/registered-mode.md +120 -63
  6. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/cli.py +1 -0
  8. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/config.py +61 -0
  9. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/poller.py +24 -8
  10. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/waiting.py +1 -0
  11. ctrlrelay-0.27.0/tests/test_allowed_accounts.py +159 -0
  12. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_waiting.py +15 -0
  13. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/dependabot.yml +0 -0
  17. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/build.yml +0 -0
  18. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/cla.yml +0 -0
  19. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/pages.yml +0 -0
  20. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/publish.yml +0 -0
  21. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.github/workflows/test.yml +0 -0
  22. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/.gitignore +0 -0
  23. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CODE_OF_CONDUCT.md +0 -0
  24. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/CONTRIBUTING.md +0 -0
  25. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/LICENSE +0 -0
  26. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/README.md +0 -0
  27. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/SECURITY.md +0 -0
  28. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/config/orchestrator.yaml.example +0 -0
  29. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/Gemfile +0 -0
  30. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/_config.yml +0 -0
  31. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/adding-a-machine.md +0 -0
  32. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/architecture.md +0 -0
  33. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/bridge.md +0 -0
  34. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/cli.md +0 -0
  35. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/development.md +0 -0
  36. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/feedback-loop.md +0 -0
  37. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/getting-started.md +0 -0
  38. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/index.md +0 -0
  39. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/llms.txt +0 -0
  40. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/operations.md +0 -0
  41. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/personalization.md +0 -0
  42. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  43. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  44. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/__init__.py +0 -0
  45. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  46. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  47. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/factory.py +0 -0
  48. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/handler.py +0 -0
  49. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  50. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  51. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  52. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/server.py +0 -0
  53. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  54. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/__init__.py +0 -0
  55. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/agent_env.py +0 -0
  56. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/archived.py +0 -0
  57. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/audit.py +0 -0
  58. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/binaries.py +0 -0
  59. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/blockers.py +0 -0
  60. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/budget.py +0 -0
  61. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  62. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/claims.py +0 -0
  63. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/code_review.py +0 -0
  64. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  65. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  66. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/events.py +0 -0
  67. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/github.py +0 -0
  68. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/github_budget.py +0 -0
  69. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  70. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/intake_hold.py +0 -0
  71. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/issue_questions.py +0 -0
  72. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/job_claims.py +0 -0
  73. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/network.py +0 -0
  74. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/node_check.py +0 -0
  75. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/obs.py +0 -0
  76. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/orphans.py +0 -0
  77. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  78. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  79. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/proof.py +0 -0
  80. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/publish.py +0 -0
  81. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  82. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/redact.py +0 -0
  83. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/reopens.py +0 -0
  84. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/scheduler.py +0 -0
  85. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/self_update.py +0 -0
  86. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/slots.py +0 -0
  87. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/state.py +0 -0
  88. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/stuck.py +0 -0
  89. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/usage.py +0 -0
  90. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
  91. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/core/worktree.py +0 -0
  92. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  93. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/dashboard/client.py +0 -0
  94. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/gh_protocol.py +0 -0
  95. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/install.py +0 -0
  96. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  97. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/manager.py +0 -0
  98. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/merge.py +0 -0
  99. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/personalization/paths.py +0 -0
  100. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  101. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/base.py +0 -0
  102. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  103. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  104. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  105. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/pipelines/task.py +0 -0
  106. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/setup.py +0 -0
  107. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/__init__.py +0 -0
  108. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  109. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  110. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  111. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  112. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  113. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  114. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  115. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  116. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/__init__.py +0 -0
  117. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/base.py +0 -0
  118. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  119. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  120. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/__init__.py +0 -0
  121. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/drift.py +0 -0
  122. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/fleet.py +0 -0
  123. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/health.py +0 -0
  124. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/links.py +0 -0
  125. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/numbers.py +0 -0
  126. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/server.py +0 -0
  127. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/static/index.html +0 -0
  128. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/status.py +0 -0
  129. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/src/ctrlrelay/web/transcript.py +0 -0
  130. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/__init__.py +0 -0
  131. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/_poller_fakes.py +0 -0
  132. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/_publish_fakes.py +0 -0
  133. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/conftest.py +0 -0
  134. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/gh_search_issues.json +0 -0
  135. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  136. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  137. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_backend_wiring.py +0 -0
  138. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_no_keys.py +0 -0
  139. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_routing.py +0 -0
  140. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_agent_sandbox.py +0 -0
  141. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_archived.py +0 -0
  142. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_audit.py +0 -0
  143. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_binary_resolution.py +0 -0
  144. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_protocol.py +0 -0
  145. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_server.py +0 -0
  146. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_bridge_start_command.py +0 -0
  147. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_budget.py +0 -0
  148. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_chat_machines.py +0 -0
  149. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_checkpoint.py +0 -0
  150. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_claim_reconciliation.py +0 -0
  151. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_ci_wait.py +0 -0
  152. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_dev.py +0 -0
  153. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_repos.py +0 -0
  154. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_secops.py +0 -0
  155. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_start.py +0 -0
  156. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_cli_version.py +0 -0
  157. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_code_review.py +0 -0
  158. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_config.py +0 -0
  159. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dashboard_client.py +0 -0
  160. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_integration.py +0 -0
  161. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_pipeline.py +0 -0
  162. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_plan_step.py +0 -0
  163. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_step_switches.py +0 -0
  164. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_stuck.py +0 -0
  165. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dev_work_steps.py +0 -0
  166. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatch_health.py +0 -0
  167. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatch_outcome_hook.py +0 -0
  168. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_dispatcher.py +0 -0
  169. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_docs_site.py +0 -0
  170. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_event_emission.py +0 -0
  171. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_events.py +0 -0
  172. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_fleet.py +0 -0
  173. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_gh_protocol.py +0 -0
  174. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_github.py +0 -0
  175. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_github_budget.py +0 -0
  176. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_install.py +0 -0
  177. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_intake_hold.py +0 -0
  178. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_intake_hold_poller.py +0 -0
  179. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_blockers.py +0 -0
  180. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_outcome_announcement.py +0 -0
  181. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_issue_questions.py +0 -0
  182. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_job_claims.py +0 -0
  183. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_mattermost_handler.py +0 -0
  184. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_max_parallel.py +0 -0
  185. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_network.py +0 -0
  186. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_no_change_run.py +0 -0
  187. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_node_check.py +0 -0
  188. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_node_claims.py +0 -0
  189. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_notify_transport.py +0 -0
  190. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_obs.py +0 -0
  191. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_opencode_adapter.py +0 -0
  192. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_orphans.py +0 -0
  193. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_personalization.py +0 -0
  194. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_personalization_merge.py +0 -0
  195. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pipeline_base.py +0 -0
  196. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller.py +0 -0
  197. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_priority_order.py +0 -0
  198. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_recheck.py +0 -0
  199. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_poller_search.py +0 -0
  200. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_post_merge.py +0 -0
  201. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pr_verifier.py +0 -0
  202. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pr_watcher.py +0 -0
  203. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_pre_dispatch_retry.py +0 -0
  204. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_proof.py +0 -0
  205. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_publish_failure_retry.py +0 -0
  206. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_backoff.py +0 -0
  207. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_expiry.py +0 -0
  208. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_question_survives_restart.py +0 -0
  209. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_redact.py +0 -0
  210. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_relay_alert_off_lock.py +0 -0
  211. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_relay_git_263.py +0 -0
  212. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_reopens.py +0 -0
  213. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_resume_prompt_contract.py +0 -0
  214. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_review_copy.py +0 -0
  215. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_review_gate.py +0 -0
  216. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_run_resume.py +0 -0
  217. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_sandbox_visibility.py +0 -0
  218. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_scheduler.py +0 -0
  219. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_integration.py +0 -0
  220. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_operator_prs.py +0 -0
  221. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_secops_pipeline.py +0 -0
  222. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_self_update.py +0 -0
  223. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_setup.py +0 -0
  224. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_state.py +0 -0
  225. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_stuck.py +0 -0
  226. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_task_pipeline.py +0 -0
  227. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_telegram_handler.py +0 -0
  228. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_timeout_save.py +0 -0
  229. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_transport.py +0 -0
  230. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_transport_resolution.py +0 -0
  231. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  232. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_usage.py +0 -0
  233. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_drift.py +0 -0
  234. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_health.py +0 -0
  235. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_links.py +0 -0
  236. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_numbers.py +0 -0
  237. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_peer_links.py +0 -0
  238. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_server.py +0 -0
  239. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_status.py +0 -0
  240. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_web_transcript.py +0 -0
  241. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_weekly_summary.py +0 -0
  242. {ctrlrelay-0.26.0 → ctrlrelay-0.27.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-11-06
11
+
12
+ ### Added
13
+
14
+ - An allowlist of accounts that can hand the relay work (#374).
15
+ `intake.allowed_accounts` lists GitHub logins, besides the relay's own
16
+ account, whose assignment to the relay or whose trigger label starts a
17
+ run. A repo can replace the list with `automation.allowed_accounts`.
18
+ Empty by default, so nothing changes until you set it.
19
+
10
20
  ## [0.26.0] - 2026-11-06
11
21
 
12
22
  ### Added
@@ -2640,7 +2650,8 @@ pipeline).
2640
2650
  per-phase implementation plans (Phase 0 through Phase 4).
2641
2651
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2642
2652
 
2643
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...HEAD
2653
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.27.0...HEAD
2654
+ [0.27.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...v0.27.0
2644
2655
  [0.26.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...v0.26.0
2645
2656
  [0.25.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...v0.25.0
2646
2657
  [0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -593,17 +593,19 @@ questions agents asked. **Never expose it to the internet.**
593
593
 
594
594
  ## intake
595
595
 
596
- The order each poll's new issues run in (#356). A poll gathers one batch of
597
- new issues and works through it before the next poll; this sets the order of
598
- that batch.
596
+ Who can hand the relay work (#374), and the order each poll's new issues run
597
+ in (#356). A poll gathers one batch of new issues and works through it before
598
+ the next poll; `priority_labels` sets the order of that batch.
599
599
 
600
600
  ```yaml
601
601
  intake:
602
+ allowed_accounts: ["teammate-1", "teammate-2"]
602
603
  priority_labels: ["P0", "P1", "P2", "P3"]
603
604
  ```
604
605
 
605
606
  | Key | Type | Default | Description |
606
607
  |---|---|---|---|
608
+ | `allowed_accounts` | list of GitHub logins | `[]` | Accounts, besides the relay's own (the account its `gh` token belongs to), that can hand it work: an issue assigned to the relay's account **by** one of them is taken, and a trigger label from `include_labels` **added by** one of them starts a run. Logins match without case; each must be a real login (no `@`) and listed once, or the config refuses to load. `[]` keeps today's rule: only the relay's own account. A repo's `automation.allowed_accounts` replaces this list for that repo. `accept_foreign_assignments: true` still takes an assignment from anyone. |
607
609
  | `priority_labels` | list of strings | `["P0", "P1", "P2", "P3"]` | Issues carrying an earlier label in this list run first; labels are compared without case, and an issue carrying several runs at its earliest. Issues carrying none of them run last. Within the same label, the oldest issue runs first (by when it was opened, then by number). An issue whose creation time cannot be read runs after the others with its label. `[]` keeps the oldest-first order only. Each label must be a non-empty string and listed once. |
608
610
 
609
611
  Each poll that found new issues logs the order it will run them in, as one
@@ -783,7 +785,8 @@ and ask the operator), or `never` (skip).
783
785
  | `secret_alerts` | `never` | Secret-scan alerts. |
784
786
  | `deploy_after_merge` | `auto` | Whether to deploy after a merged PR. |
785
787
  | `operator_prs` | `ask` | `ask` or `ignore`. What the secops sweep does with your own open PRs that touch more than `.github/dependabot.yml`. See [operator_prs](#reposautomationoperator_prs) below. |
786
- | `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by someone else. Default (`false`) runs the dev pipeline only on issues you self-assigned. |
788
+ | `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by anyone else. Default (`false`) runs the dev pipeline only on issues you self-assigned, or that an account in `allowed_accounts` assigned to you. |
789
+ | `allowed_accounts` | `null` | Accounts whose assignment or trigger label hands this repo's issues to the relay, besides the relay's own account. `null` uses [`intake.allowed_accounts`](#intake); a list, even `[]`, replaces it for this repo. |
787
790
  | `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
788
791
  | `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
789
792
  | `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
@@ -879,10 +882,11 @@ repos:
879
882
  - An issue is accepted when **either** (a) it's assigned to the configured
880
883
  user (subject to the self-assignment filter from #79 and
881
884
  `accept_foreign_assignments`) **or** (b) it carries any label in
882
- `include_labels` **that the configured user added** (the latest
883
- `labeled` event for that label names that account; `accept_foreign_assignments`
884
- does not widen this). A label someone else added does not start a run;
885
- the issue is still taken if the user also assigned it to themselves.
885
+ `include_labels` **that the configured user, or an account in
886
+ `allowed_accounts`, added** (the latest `labeled` event for that label
887
+ names one of them; `accept_foreign_assignments` does not widen this). A
888
+ label anyone else added does not start a run; the issue is still taken
889
+ if the user also assigned it to themselves.
886
890
  - Dedup: an issue that is **both** labeled and assigned is picked up exactly
887
891
  once per poll cycle — no duplicate entries in `seen_issues` and no double
888
892
  pipeline spawn.
@@ -0,0 +1,350 @@
1
+ ---
2
+ title: Control plane as the brain (design)
3
+ layout: default
4
+ nav_order: 13
5
+ description: "Design: how the control plane holds knowledge, memory, roles and credentials for every node, so a node can start clean, and how that is kept safe."
6
+ permalink: /control-plane-brain/
7
+ ---
8
+
9
+ # The control plane as the brain: design
10
+
11
+ **Status: agreed 2026-11-06, not built.** This is issue #372. It builds on
12
+ [Registered mode]({{ '/registered-mode/' | relative_url }}) (#251), which
13
+ fixes the protocol between a node and the control plane. That protocol was
14
+ first written on the assumption that the control plane never puts text in
15
+ front of an agent and never holds a credential. This page drops that
16
+ assumption, on purpose, and says what replaces it. The owner's decisions are at the end, under "Decisions".
17
+
18
+ ## The idea in one paragraph
19
+
20
+ The control plane holds what the factory **knows** (context, learnings,
21
+ decisions, history), what each kind of work **is** (roles, with their
22
+ instructions, skills and agent settings) and, where a role needs it, the
23
+ **credentials** for that work. A node holds the **runtime**: ctrlrelay, the
24
+ agent CLI and its login, the sandbox, its own GitHub token and its key. A
25
+ node with an empty cache registers, takes an assignment, fetches what that
26
+ assignment names, and runs. Losing a node loses no knowledge.
27
+
28
+ **The safety rule that makes this possible: anything that changes what an
29
+ agent is told, or what it can reach, is signed by the operator, with a key
30
+ the control plane server does not hold.** The control plane stores and hands
31
+ out that content; it cannot author it. Everything else it sends is data, and
32
+ a node shows data to an agent only as data.
33
+
34
+ ## What exists today, and what it becomes
35
+
36
+ | Today, on each node | Becomes |
37
+ |---|---|
38
+ | `<paths.contexts>/<owner-repo>/CLAUDE.md`, linked into each worktree | **Repo context**, a signed knowledge entry per repo |
39
+ | Skills under `paths.skills`, synced by [personalization]({{ '/personalization/' | relative_url }}) | Skills inside a signed **role** bundle |
40
+ | `agent:` block in each node's config (type, model, sandbox paths) | Agent settings inside a role, within the node's own limits |
41
+ | Per-repo learning (#225), planned in the repo on GitHub (decision 5 of #251) | **Learnings**, proposed by nodes, approved and signed by the operator |
42
+ | Plans saved under the contexts directory (#220) | **History**: plans, run summaries and outcomes, stored as data |
43
+ | Third-party secrets in the node's environment | **Role credentials**, sealed to one node for one assignment |
44
+
45
+ Personalization sync and the contexts directory keep working in standalone
46
+ mode. A registered node with brain content enabled ignores them for runs
47
+ that carry a role.
48
+
49
+ ## Three kinds of content
50
+
51
+ Everything the control plane holds falls in one of three kinds. The kind
52
+ decides how it is protected and how an agent sees it.
53
+
54
+ | Kind | Examples | Signed by the operator | How the agent sees it |
55
+ |---|---|---|---|
56
+ | **Instructions** | roles, skills, repo context, approved learnings, decisions, config | yes | as instructions, in the prompt or as skill files |
57
+ | **Data** | run history, plans, summaries of earlier runs, proposed learnings, numbers | no | inside a fenced block marked as data, never as instructions, like issue text today |
58
+ | **Secrets** | role credentials | no (sealed instead, see "Credentials") | never: only the step that needs one gets it, as an environment variable |
59
+
60
+ The protocol content from #251 (assignments, commands, leases) is data too:
61
+ it says **what** to work on, never **how**.
62
+
63
+ ### Signing
64
+
65
+ - The operator holds an **operator key** (Ed25519) off the control plane
66
+ server: on a hardware key or the operator's own machine.
67
+ - The node pins the operator's public keys in its **local** `control_plane`
68
+ block, which the control plane can never change (decision 6 of #251).
69
+ - Every instruction item is a **bundle**: a JSON manifest plus files, named
70
+ by the SHA-256 of its content, with `name`, `version`, `not_after` and the
71
+ operator's signature over all of it.
72
+ - A node refuses a bundle that is unsigned, signed by a key it does not pin,
73
+ past `not_after`, revoked, or below the floor (next point). A refused
74
+ bundle fails the assignment with `bundle_refused`, which the node reports
75
+ and posts in chat. It never runs without it.
76
+ - **The policy list** stops replays. It is one small signed document: a
77
+ sequence number, a `not_after` (default 7 days), the **minimum version**
78
+ of each bundle name and of the config, and the revoked digests. The node
79
+ fetches it with the heartbeat and keeps the highest sequence number it has
80
+ seen; it refuses a list with a lower number or past its `not_after`.
81
+ Because the floor is signed, a wiped node that remembers nothing still
82
+ refuses an old role or config. The operator re-signs the list at least
83
+ every `not_after` (one `ctrlrelay` command on the operator's machine),
84
+ and whenever a bundle is revoked or a floor is raised.
85
+ - No valid policy list (unreachable, or expired) is "could not check", not
86
+ "nothing revoked": the node finishes runs already going, and takes no new
87
+ assignment with a role, and applies no new pushed config, until it has a
88
+ valid list again. It keeps the last good config meanwhile.
89
+ - **Known limit:** a node wiped of its state remembers no sequence number, so
90
+ until its first valid list it can be handed an older one that has not yet
91
+ expired. The window is the list's `not_after`, at most 7 days by default;
92
+ a shorter `not_after` narrows it at the cost of re-signing more often.
93
+
94
+ Signing is the approval step. The operator approves a role change, a context
95
+ edit or a learning in the control plane's UI, the UI asks the operator's key
96
+ to sign it, and only then does it become something a node will use.
97
+
98
+ ## Knowledge and memory
99
+
100
+ **What is stored, per repo:**
101
+
102
+ - **Context:** what `CLAUDE.md` in the contexts directory holds today: how
103
+ the repo is built and tested, its rules, its layout. Instructions.
104
+ - **Learnings:** facts from real runs (#225): build and test commands that
105
+ worked, flaky tests, common failure causes. Each one carries its evidence:
106
+ the runs, by assignment id, and the result that showed it. Instructions,
107
+ once approved.
108
+ - **Decisions:** design decisions an agent should respect, with a link to
109
+ where they were made (an issue, a doc). Instructions.
110
+ - **History:** per run, the assignment, outcome, PR number, phase reached and
111
+ a short scrubbed summary written by the node. Data.
112
+
113
+ **Who writes it:**
114
+
115
+ - The **operator** writes context and decisions directly.
116
+ - **Nodes** propose learnings, from measured results only, never because an
117
+ agent said so, as #225 asks. A proposal goes to the control plane as data.
118
+ - **Agents** may suggest a learning in their checkpoint. The node forwards it
119
+ as a proposal marked "agent suggestion", with the run it came from.
120
+ - Nothing proposed reaches a later run until the operator approves it (see
121
+ "Decisions", decision 4).
122
+
123
+ **How it reaches a run.** The assignment names a **knowledge snapshot** for
124
+ the repo: one signed bundle holding the context, approved learnings and
125
+ decisions, at a version. The node assembles the prompt as it does today, and
126
+ puts the snapshot in its own labelled section. Recent history for the issue
127
+ and repo (for example the last three runs on this issue) goes in a separate
128
+ fenced section marked as data. Both have a size cap, and the node, not the
129
+ control plane, decides where they sit in the prompt.
130
+
131
+ **How stale entries go:**
132
+
133
+ - A learning names what would contradict it (for example, "the test command
134
+ is X": a later run where X fails for a reason other than the code). A node
135
+ that measures a contradiction proposes dropping it, with its evidence.
136
+ - Learnings not confirmed by a run in 90 days are flagged for the operator,
137
+ not dropped silently.
138
+ - Dropping or editing an approved entry is a new signed snapshot, so every
139
+ run can say which version it used. The run's report carries the snapshot
140
+ digest.
141
+
142
+ ## Roles
143
+
144
+ A **role** is a signed bundle that says what a node is asked to be for one
145
+ piece of work:
146
+
147
+ ```json
148
+ {
149
+ "name": "builder",
150
+ "version": 7,
151
+ "pipelines": ["dev"],
152
+ "instructions": "role.md",
153
+ "skills": ["skills/tdd/SKILL.md", "skills/review/SKILL.md"],
154
+ "agent": {"type": "claude", "model": "default", "max_turns": 200},
155
+ "sandbox": {"extra_readonly": [], "extra_writable": [], "network": "default"},
156
+ "credentials": ["sentry-read"],
157
+ "repos": ["owner/app", "owner/lib"],
158
+ "not_after": 1800000000
159
+ }
160
+ ```
161
+
162
+ First roles, matching what the relay already does: **builder** (the dev
163
+ pipeline), **reviewer** (the review step), **triager** (#227), **planner**
164
+ (the plan step, #220). More are added by the operator, not by code.
165
+
166
+ **How a node takes one on.** The assignment from #251 gains two fields:
167
+
168
+ ```
169
+ "role": {"name": "builder", "digest": "sha256:4be1..."},
170
+ "knowledge": {"repo": "owner/app", "digest": "sha256:91c0..."}
171
+ ```
172
+
173
+ The node fetches both by digest (`GET /v1/bundles/<digest>`), unless cached,
174
+ verifies them, and checks both against the assignment: the pipeline and
175
+ repo must be in the role's lists, and the knowledge snapshot must be for the
176
+ assignment's repo. It writes the skills into a fresh
177
+ directory for this run only, outside `~/.claude/skills`, and points the agent
178
+ at it. The directory is removed when the run ends. Nothing a role brings
179
+ outlives its run.
180
+
181
+ **What a node still refuses.** Each node keeps a local `role_limits` block,
182
+ which the control plane cannot change:
183
+
184
+ ```yaml
185
+ control_plane:
186
+ operator_keys: ["ed25519:...", "ed25519:..."]
187
+ role_limits:
188
+ agents: [claude] # agent types a role may ask for
189
+ max_extra_writable: [] # paths a role may add as writable; empty = none
190
+ network: [default] # sandbox network modes a role may ask for
191
+ credentials: true # whether this node accepts role credentials
192
+ ```
193
+
194
+ A role that asks for more than these limits allow is refused
195
+ (`role_exceeds_limits`), even when signed. The signature says the operator
196
+ wrote it; the limits say what this machine allows. The sandbox from #219
197
+ still applies to every run.
198
+
199
+ ## Starting clean
200
+
201
+ **What a node keeps locally, and why:**
202
+
203
+ | Kept on the node | Why |
204
+ |---|---|
205
+ | ctrlrelay, the agent CLI, bwrap | the runtime; the control plane sends no code |
206
+ | the agent login | it is an account, not a role credential (decision 3) |
207
+ | its GitHub token | the node reads issues and opens PRs with its own access (#251, rule 3) |
208
+ | node key, `node_id`, the `control_plane` block with `operator_keys` and `role_limits` | without them the node cannot reach the control plane, prove who it is, or check what it is sent |
209
+ | the state database, worktrees, the report queue | work in progress; claims on GitHub say who holds an issue |
210
+ | a bundle cache, by digest | speed only; safe to delete at any time |
211
+
212
+ **What it gets:** with its first heartbeat after registering, the signed
213
+ config (decision 6 of #251) and the policy list; at each assignment, the role and knowledge snapshot it
214
+ names, and any role credentials.
215
+
216
+ **What losing a node costs:** its runs in progress (their claims go stale and
217
+ another node takes over, as #251 describes) and any reports still queued on
218
+ disk. To keep that small, a node sends a learning proposal as soon as it has
219
+ one, not at the end of the run.
220
+
221
+ **The test for "starts clean":** take a node that has run an issue, delete
222
+ everything but the runtime, the agent login, the GitHub token and the local
223
+ `control_plane` block, register it again, and run the same issue. Its prompt
224
+ holds the same knowledge section, byte for byte, for the same snapshot
225
+ digest.
226
+
227
+ ## Credentials
228
+
229
+ **Two classes, kept apart:**
230
+
231
+ - **Node credentials:** the node key, the GitHub token, the agent login.
232
+ They never leave the node, as #251 rule 3 says today.
233
+ - **Role credentials:** secrets a role needs for its work, for example a
234
+ read token for an error tracker, a package registry token, a scanner's API
235
+ key. These live on the control plane.
236
+
237
+ **How one moves:**
238
+
239
+ 1. The role names it (`"credentials": ["sentry-read"]`). A node takes only
240
+ credentials named by the signed role it is running, and only if its
241
+ `role_limits.credentials` is true.
242
+ 2. At enrolment (`ctrlrelay node enroll`) the node also makes a second key
243
+ pair, X25519, for receiving secrets, and sends its public half at
244
+ register. The control plane seals each credential to that one node's key,
245
+ for that one assignment (`assignment_id` and an expiry inside the sealed
246
+ box). Nothing in transit or in the control plane's logs is readable.
247
+ 3. The node opens it, gives it only to the step that needs it, as an
248
+ environment variable, and adds the exact value to the run's redaction
249
+ list so it is scrubbed from every log, report and chat line.
250
+ 4. It is never written into the prompt, the worktree or a file on disk, and
251
+ it is dropped when the run ends, on release, and on revocation.
252
+
253
+ **Scope and life.** Prefer a token the control plane mints per assignment
254
+ (scoped to one repo, living as long as the lease) over a long-lived secret.
255
+ A long-lived secret is allowed, with its scope written in the role, and is
256
+ rotated at its source when a node holding it is revoked.
257
+
258
+ **What this does not cover:** the GitHub token and the agent login. They
259
+ stay on the node for now (decision 3). That may change: some tokens may be
260
+ better held centrally, and one shape for that is the control plane acting as
261
+ a hub, where the agent asks it to perform an action and it acts with a token
262
+ the node never sees. That needs its own design before it is built.
263
+
264
+ ## Threat model
265
+
266
+ Registered mode's threat model assumed a compromised control plane could
267
+ learn little and steer nothing. With the brain, the control plane holds the
268
+ factory's instructions and some of its secrets, so protecting it becomes the
269
+ main security job. Three attackers:
270
+
271
+ **1. Someone controls the control plane server, but not the operator key.**
272
+
273
+ | They can | They cannot, because |
274
+ |---|---|
275
+ | read all knowledge, history and roles | (nothing stops this; it is not secret from the server) |
276
+ | read role credentials it holds | (nothing stops this; keep them scoped and short-lived) |
277
+ | withhold work, route it badly, pause nodes | (as in #251) |
278
+ | insert false history | history is data, fenced, like issue text |
279
+ | | change a role, a skill, context or an approved learning: no operator signature |
280
+ | | hand an agent a new instruction: only signed content is shown as instructions |
281
+ | | roll a node back to an older, weaker role or config: the signed policy list sets a floor, and a node refuses a list older than one it has seen |
282
+ | | widen what a role may do on a node: `role_limits` is local |
283
+ | | plant a learning: proposals need the operator's approval and signature |
284
+ | | give a node a credential its role does not name |
285
+
286
+ Config is signed too (decision 2), so this attacker cannot change a node's
287
+ repos, sandbox paths or schedules either.
288
+
289
+ **2. Someone holds the operator key.** They control what every agent is told.
290
+ So the key lives off the server, ideally on a hardware key; there can be two
291
+ pinned keys so one can be rotated or revoked without touching every node by
292
+ hand; and every signature is logged with what it signed.
293
+
294
+ **3. Someone controls one node.** They can read the bundles and credentials
295
+ of the assignments that node gets, and all knowledge for the repos in its
296
+ allowlist. They can propose false learnings, which the operator then sees
297
+ with the node's name on them. Revoke the node's key, rotate the role
298
+ credentials it held, and the rest carry on.
299
+
300
+ **Protecting the control plane itself:** operator access behind SSO with a
301
+ second factor; an append-only audit log of every approval, signature, config
302
+ push and credential handed out; role credentials encrypted at rest; the
303
+ signing step on the operator's side, never a server job.
304
+
305
+ ## Changes to registered mode
306
+
307
+ - **Rule 3** becomes: "Node credentials (the node key, the GitHub token, the
308
+ agent login) never leave the node. Role credentials may come from the
309
+ control plane, sealed to one node for one assignment."
310
+ - **Assignments** gain `role` and `knowledge` digests (above). Without them
311
+ a run behaves exactly as today.
312
+ - **Threat model:** the row "put its own instructions in front of the agent"
313
+ changes to "unless signed with an operator key"; the row "get a credential"
314
+ narrows to node credentials.
315
+ - **Config from the control plane** (decision 6 of #251) must be signed with
316
+ an operator key (decision 2 here). An unsigned config is refused whole.
317
+ - **Non-goal** "the control plane holding or brokering GitHub or agent
318
+ credentials" stays for now (decision 3).
319
+
320
+ ## Build order
321
+
322
+ 1. **The protocol from #251, as agreed.** Reserve the `role` and
323
+ `knowledge` fields in assignments and the bundle fetch path, so nothing
324
+ here needs it reworked.
325
+ 2. **Bundles and signing:** the manifest format, `ctrlrelay` commands for the
326
+ operator to sign, node-side checks, the cache, the revocation list.
327
+ 3. **Knowledge:** repo context and approved learnings as snapshots, the
328
+ history section in the prompt, learning proposals from nodes (this is
329
+ #225 moved to the control plane).
330
+ 4. **Roles:** builder, reviewer, triager, planner as bundles, `role_limits`,
331
+ per-run skill directories.
332
+ 5. **Role credentials:** the second key, sealed delivery, redaction.
333
+
334
+ ## Decisions
335
+
336
+ Made by the owner on 2026-11-06 (see #372).
337
+
338
+ 1. **Signing:** everything that steers an agent (roles, skills, repo
339
+ context, approved learnings, decisions) is signed by an operator key held
340
+ off the control plane server.
341
+ 2. **Config is signed too.** This adds a signature to decision 6 of #251: the
342
+ control plane may still set the full config, but a node applies only a
343
+ config signed with an operator key.
344
+ 3. **GitHub token and agent login stay on the node, for now.** A node starts
345
+ clean of knowledge, not of its own accounts. This may change: some tokens
346
+ could be set centrally, possibly with the control plane as a hub that
347
+ performs an action for the agent instead of handing over the token.
348
+ Revisit with its own design.
349
+ 4. **Every learning needs the operator's approval**, measured or suggested,
350
+ before a later run sees it.