* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers
Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.
When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.
Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.
* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem
* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)
Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).
ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.
Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.
* feat(panel): full mobile responsiveness pass
Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).
Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.
Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).
* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)
ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.
Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.
Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.
Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.
* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)
ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.
The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.
XPostService.approve (CEO-only) is the sole caller of post_tweet.
Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.
* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)
ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).
The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.
Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).
Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).
* chore(release): 0.17.0
Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.
Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.
Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).
* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes
Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.
* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds
The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.
* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)
The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
_list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
MODULE-level _is_held_ceo_source (module, not method, so the
wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)
Behavior-preserving; full suite green (11902), xenon clean.
* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)
The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.
* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)
Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.
New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.
* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes
Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
token (header-trust/secure mode, cloud auth off) bounced the user to a
login page whose backend route isn't mounted -> 404. Now it probes
/auth/status (bare fetch, no interceptor re-entry) and only redirects
when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
token signed with the quotes included — which never verifies against
the orchestrator (docker-compose/pydantic unquote the secret). Now
strips surrounding single/double quotes.
* fix: git-log 500 on '|' in commit message; X queue shows an empty state
- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
fixed '|' field delimiter let the subject's pipe shift the split so
author+date collapsed into one field. Switched to \x1f (Unit
Separator), which can't appear in commit content. Regression test with
a piped subject.
- The X Post Queue returned null when empty, so there was no visible
place for the X drafts. It now renders a discoverable empty state
pointing at Settings -> X credentials.
* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)
Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
(+ x_replies_enabled sub-flag), board roadmap engine — new RAG
architecture pages + role/tool/config-reference updates; new symbols,
migrations 057-059, panel surfaces, and the get_agent_context
dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
(search_past_tasks), Secretary edit access + PM-lighter scope, the
PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
routes are reachable by any authenticated agent — they carry a
_require_ceo gate (wave 2c).
docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.
* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)
The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.
Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
38 KiB
Slice Map — product-strategy-research-pitch
Purpose
The product / strategy / research / pitch slice covers the "company layer" above the delivery lifecycle: registering the git repositories agents work on (Project), mapping cells to repos within a product (Product), rendering role-specific kanban views (Kanban), the singleton company charter (CompanyGoals), the dormant goal-drift watcher (StrategyEngine), the pluggable web-search capability for Board/PM agents (Research + ResearchQuota), Board pitches with CEO-approve → auto-provision (Pitch), and the single GitHub repo-creation service that backs provisioning (GitHubProvisioning). Together these are the CEO/Board-facing surface that originates work and feeds it into the normal delivery lifecycle, plus the per-cell routing keystone that the gateway delegate path consults at runtime.
Files
| Path | Role | approx LOC |
|---|---|---|
roboco/services/project.py |
CRUD + git-token encryption + cell access control for Projects (git repos) | 604 |
roboco/services/product.py |
Product CRUD + per-cell project_for routing resolver + idempotent cell-map replace |
152 |
roboco/services/kanban.py |
Role-specific kanban board views (dev/qa/documenter/pm/main-pm/board) from task data | 587 |
roboco/services/company_goals.py |
CRUD for the singleton company charter (north star + objectives + constraints + policy) | 83 |
roboco/services/strategy_engine.py |
Dormant "engine 2": assesses company state vs goals, notify-only to CEO | 111 |
roboco/services/research.py |
Pluggable web-search/fetch — provider adapters (Tavily/Brave/Exa/Null) + clamping service | 431 |
roboco/services/research_quota.py |
Per-agent UTC-daily Redis quota counter for research calls (fail-open) | 78 |
roboco/services/pitch.py |
Board pitch CRUD + CEO approve → provision repos/Projects(+Product) + seed Main-PM task | 274 |
roboco/services/github_provisioning.py |
The only service that CREATES GitHub repos (POST /orgs/{org}/repos) |
174 |
roboco/services/roadmap_engine.py |
Dormant weekly engine: originates ONE held roadmap-exploration task for the Product Owner (default off) | 111 |
roboco/services/roadmap_service.py |
CEO's per-item approve/reject glue over a held roadmap cycle; approve materializes a BACKLOG task | 211 |
roboco/api/routes/roadmap.py |
CEO-only routes: list open cycles, approve/reject one item | 124 |
Key Symbols
| Name | Kind | File:Line | Responsibility |
|---|---|---|---|
ProjectService |
class | project.py:23 | Project CRUD, slug lookup, cell listing, workspace path, git-token encrypt/decrypt, agent access control |
ProjectService._assert_git_url_allowed |
method | project.py:40 | Reject git_url matching a protected/denylisted repo |
ProjectService.create |
method | project.py:56 | Register project, encrypt token, best-effort conventions scaffold |
ProjectService._maybe_scaffold_conventions |
method | project.py:126 | Lazy-imports conventions service; scaffold hiccup is non-fatal |
ProjectService.update |
method | project.py:166 | Selective field apply; empty-string git_token clears, None leaves unchanged |
ProjectService.delete |
method | project.py:226 | Abandons ACTIVE work sessions first; optional on-disk workspace cleanup; DB RESTRICT on tasks |
ProjectService.get_decrypted_token / _by_slug |
method | project.py:441 / 471 | On-demand Fernet decrypt of git token (never cached) |
ProjectService.check_agent_access |
method | project.py:559 | Cell membership + optional allowed_agents allowlist |
get_project_service |
factory | project.py:602 | Session-bound constructor |
ProductService |
class | product.py:15 | Product CRUD + cell→project routing |
ProductService.project_for |
method | product.py:87 | Per-cell routing keystone — resolve Project for (product, team); None → caller falls back to parent task's project |
ProductService.distinct_project_ids |
method | product.py:103 | Distinct repos a product spans (one Main-PM integration branch each) |
ProductService._replace_cells |
method | product.py:122 | Idempotent full cell-map replace via cascade collection; flushes DELETEs before INSERTs to avoid uq_product_projects_product_team 409 |
get_product_service |
factory | product.py:150 | Session-bound constructor |
KanbanService |
class | kanban.py:30 | Role-specific board generation with optional swimlanes |
KanbanService._load_subtask_counts |
method | kanban.py:47 | Batch-count direct children per parent in ONE grouped query (fixes the always-0 stub; #198) |
KanbanService._task_to_card |
method | kanban.py:61 | Task → KanbanCard; accepts optional subtask_counts dict for real subtask counts |
KanbanService.get_dev_board |
method | kanban.py:117 | Dev cell board with optional priority/assignee swimlanes |
KanbanService.get_qa_board / get_documenter_board / get_pm_board |
method | kanban.py:335 / 366 / 400 | Role-filtered flat boards |
KanbanService.get_main_pm_board / _flat |
method | kanban.py:419 / 435 | Cross-cell view (team swimlanes / team columns) |
KanbanService.get_board_kanban |
method | kanban.py:538 | Board roadmap (P0/P1 only) |
KanbanService.get_board_stats |
method | kanban.py:558 | Status-count aggregation |
CompanyGoalsService |
class | company_goals.py:35 | Singleton charter CRUD |
SINGLETON_ID |
constant | company_goals.py:23 | Fixed UUID 0…0 — charter is one row |
CompanyGoalsService.get / upsert |
method | company_goals.py:38 / 44 | Partial-key upsert; caller commits |
StrategyEngine |
class | strategy_engine.py:47 | Assess company state vs goals |
StrategyEngine.assess |
method | strategy_engine.py:52 | Pure read; emits idle + stranded_blocked observations |
StrategyEngine.run_cycle |
method | strategy_engine.py:92 | No-op unless flag on; assess + notify CEO (notify-only, never spends/builds) |
StrategyObservation |
dataclass | strategy_engine.py:38 | Frozen (kind, summary, detail) |
ResearchService |
class | research.py:337 | Provider-agnostic entry point; clamps result/char caps |
SearchProvider |
ABC | research.py:80 | Adapter base: shared/owned httpx client, _request_json, abstract search, default-unsupported fetch |
TavilyProvider / BraveProvider / ExaProvider |
class | research.py:162 / 212 / 242 | Concrete adapters (Brave has no fetch) |
NullProvider |
class | research.py:288 | Graceful stub — empty results, configured=False, never raises |
build_provider |
func | research.py:317 | Select adapter by name; NullProvider when no key or unknown name |
get_research_service |
factory | research.py:391 | Build service from settings (provider + caps) |
SearchHit / SearchOutcome / FetchOutcome |
dataclass | research.py:45 / 55 / 65 | Normalised result shapes |
ResearchError / ResearchUnsupportedError |
exception | research.py:32 / 36 | Provider failure vs unsupported-op (route maps 502 vs 501) |
ResearchQuotaTracker |
class | research_quota.py:34 | Per-agent/day Redis INCR counter, 24h expiry, fail-open |
ResearchQuotaTracker.check_and_consume |
method | research_quota.py:51 | Atomic INCR-then-compare; over-limit still bumps counter |
QuotaStatus |
dataclass | research_quota.py:24 | (allowed, used, limit, day) |
PitchService |
class | pitch.py:56 | Pitch CRUD + approve/reject |
PitchService.approve |
method | pitch.py:109 | Provision repos → register topology → seed Main-PM task; idempotent on re-approval |
PitchService._provision_repos |
method | pitch.py:163 | One repo per target cell (multi-cell suffixes cell); reuses existing Project by slug |
PitchService._register_topology |
method | pitch.py:201 | Multi-cell → Product (reuse existing by slug + refresh cell map); single-cell → seed project only |
PitchService._seed_main_pm_task |
method | pitch.py:234 | Creates PENDING Main-PM CODE task (source="pitch", confirmed_by_human=True) |
PitchService._proposed_or_raise |
method | pitch.py:152 | 404 if missing, 409 if not proposed (no re-deciding) |
GitHubProvisioningService |
class | github_provisioning.py:45 | Create private repos in configured org |
GitHubProvisioningService.enabled |
prop | github_provisioning.py:67 | True only when master switch + token + org all set |
GitHubProvisioningService.create_repo |
method | github_provisioning.py:81 | POST /orgs/{org}/repos with auto_init=true; handles GitHub 422 "already exists" idempotently via _fetch_existing_repo (#83/#84) |
GitHubProvisioningService._fetch_existing_repo |
method | github_provisioning.py:140 | GET org/name and reconstruct ProvisionedRepo — called on 422 to reuse an orphaned repo from a rolled-back prior approval |
_GITHUB_REPO_EXISTS_STATUS |
constant | github_provisioning.py:42 | 422 — GitHub's "name already exists" status sentinel |
ProvisionedRepo / ProvisioningError / ProvisioningDisabledError |
dataclass/exc | github_provisioning.py:32 / 23 / 27 | Result + error types |
RoadmapEngine |
class | roadmap_engine.py:49 | Dormant "engine 3": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape, but the artifact is a cycle the PO authors, not a report the engine assembles |
RoadmapEngine.run_cycle |
method | roadmap_engine.py:54 | No-op unless roadmap_engine_enabled, a cycle is already open (list_open_roadmap_cycles), or the RoboCo project isn't resolvable; else opens ONE held PENDING exploration task assigned to the Product Owner |
RoadmapService |
class | roadmap_service.py:50 | List / approve / reject items within the open roadmap cycle(s) |
RoadmapService.approve_item |
method | roadmap_service.py:59 | Materialize one proposed item as a BACKLOG task via PrompterService.create_task_from_draft; idempotent per item |
RoadmapService.reject_item |
method | roadmap_service.py:108 | Record the CEO's reason; idempotent; an already-approved item cannot be rejected |
RoadmapService._find_item |
method | roadmap_service.py:146 | Resolve (exploration task, deep-copied cycle payload, one item) — deep copy so mutation doesn't poison SQLAlchemy's dirty-check before markers.set_roadmap_cycle reassigns |
RoadmapService._maybe_complete_cycle |
staticmethod | roadmap_service.py:202 | Completes the exploration task once every item on it is terminal (approved/rejected) |
RoadmapItemResult |
dataclass | roadmap_service.py:37 | Outcome of one approve/reject call (status/item_id/materialized_task_id/detail) |
get_roadmap_engine / get_roadmap_service |
factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors |
Data Flow
Two distinct flows originate work into the delivery lifecycle:
Pitch flow (CEO-driven origination). A Board member creates a pitch (PitchService.create → PitchTable status proposed). The CEO approves via POST /api/pitch/{id}/approve → PitchService.approve. Approval calls GitHubProvisioningService.create_repo once per target cell (repo name {slug}-{cell} when multi-cell, else {slug}), then ProjectService.create to register each repo as a Project (git token stored from settings.provisioning_token). For multi-cell pitches, ProductService.create registers a Product with the cell→project map; for single-cell, the lone project is the seed. _seed_main_pm_task then creates a PENDING Main-PM CODE task (source="pitch", confirmed_by_human=True) assigned to main-pm, which the normal dispatcher picks up. The pitch row moves to provisioned with provisioned_product_id / provisioned_project_ids / seed_task_id recorded.
Strategy flow (dormant watcher). Orchestrator._strategy_engine_loop (created at startup) returns immediately unless strategy_engine_enabled; otherwise each strategy_engine_interval_seconds it opens a DB context and calls StrategyEngine.run_cycle → assess. assess reads TaskService.list_in_progress_or_claimed and list_long_running_blocked against CompanyGoalsService.get(); if idle-with-goals or stranded-blocked, it sends the CEO an ack-notification via NotificationService.send_ack_notification. Notify-only — never originates work.
Research flow (on-demand agent capability). A Board/PM agent calls the roboco-search MCP tool (mounted only when research_enabled and role is research-eligible, orchestrator line 2914) → /api/research/{search,fetch} route. The route enforces the per-agent daily quota via the module-level ResearchQuotaTracker singleton (Redis INCR, fail-open), then calls get_research_service() → ResearchService.search/fetch → selected provider adapter. Result count and char size are clamped to research_max_results / research_fetch_max_chars. The provider key lives only server-side; the agent never egresses.
Routing flow (runtime keystone). ProductService.project_for(product_id, team) is called from the gateway delegate path to resolve which Project a cell works on within a product; None falls back to the parent task's project.
Roadmap flow (dormant weekly originator, default off). Orchestrator._roadmap_engine_loop returns immediately unless roadmap_engine_enabled; otherwise each roadmap_interval_seconds (default weekly) it opens a DB context and calls RoadmapEngine.run_cycle, which no-ops if a roadmap-source task is already open or the RoboCo project isn't resolvable, else opens ONE held PENDING exploration task (source=board_roadmap, confirmed_by_human=False) assigned to the Product Owner. The normal board one-shot dispatch (_dispatch_roadmap_exploration) spawns the PO, who explores the charter/releases/metrics/projects and calls the propose_roadmap do-tool exactly once with a themed goal + 3-7 item drafts (persisted as an orchestration_markers payload). The CEO reviews the cycle in the panel's Roadmap Review Queue and approves/rejects each item individually via /api/roadmap/cycles/{id}/items/{id}/{approve,reject} → RoadmapService; an approved item materializes as a BACKLOG task (source=roadmap) through PrompterService.create_task_from_draft — nothing auto-starts, normal PM activation takes it from BACKLOG. Once every item is terminal, the exploration task itself completes.
Read-only views. KanbanService builds role-specific boards from TaskTable queries on demand for the kanban API; CompanyGoalsService.get is read by the briefing injector into every agent's context_briefing.
Mermaid
flowchart TD
Board[Board member] -->|create| Pitch[PitchService.create]
CEO -->|approve /api/pitch/:id/approve| Approve[PitchService.approve]
Approve --> Prov[GitHubProvisioningService.create_repo]
Prov --> GH[(GitHub org repos)]
Approve --> ProjReg[ProjectService.create per cell]
Approve --> Topo{_register_topology}
Topo -->|multi-cell| ProdCreate[ProductService.create]
Topo -->|single-cell| SeedProj[seed_project_id]
Approve --> Seed[_seed_main_pm_task]
Seed --> MainPmTask[PENDING Main-PM task source=pitch]
MainPmTask --> Dispatcher[normal delivery lifecycle]
subgraph StrategyLoop[dormant — strategy_engine_enabled]
Loop[Orchestrator._strategy_engine_loop] -->|interval| Cycle[StrategyEngine.run_cycle]
Cycle --> Assess[assess]
Assess --> Goals[(CompanyGoalsService.get)]
Assess --> Tasks[(TaskService list_in_progress / long_running_blocked)]
Assess -->|notify-only| NotifyCEO[CEO notification]
end
subgraph ResearchFlow[on-demand — research_enabled]
Agent[Board/PM agent] -->|roboco-search MCP| Route[/api/research/]
Route --> Quota[ResearchQuotaTracker.check_and_consume]
Quota --> Redis[(Redis INCR/day)]
Route --> RS[ResearchService]
RS --> Prov2{"Tavily|Brave|Exa|Null"}
Prov2 -->|provider API| Web[(Web)]
end
subgraph RoadmapLoop[dormant — roadmap_engine_enabled]
RLoop[Orchestrator._roadmap_engine_loop] -->|interval, default weekly| RCycle[RoadmapEngine.run_cycle]
RCycle -->|held PENDING task| PO[Product Owner spawn]
PO -->|propose_roadmap do-tool| Payload[(orchestration_markers cycle payload)]
CEO -->|approve/reject per item /api/roadmap| RSvc[RoadmapService]
RSvc -->|approve| Backlog[BACKLOG task via PrompterService]
RSvc -->|all items terminal| Complete[exploration task completes]
end
Logical Tree
product-strategy-research-pitch
├── project.py — ProjectService
│ ├── CRUD (create/get/get_by_slug/get_or_raise/update/delete)
│ ├── _assert_git_url_allowed (protected denylist)
│ ├── _maybe_scaffold_conventions (flag-gated, non-fatal)
│ ├── queries (list_all / list_by_cell)
│ ├── workspace (set_workspace_path / update_sync_state)
│ ├── git token (get_decrypted_token / _by_slug) — Fernet, on-demand
│ └── access control (add/remove_allowed_agent / check_agent_access)
├── product.py — ProductService
│ ├── CRUD
│ ├── project_for (per-cell routing keystone)
│ ├── distinct_project_ids (Main-PM integration-branch set)
│ └── _replace_cells (idempotent map replace, DELETE-before-INSERT flush)
├── kanban.py — KanbanService
│ ├── _load_subtask_counts (batch child-count query, fixes always-0 stub)
│ ├── _task_to_card (accepts subtask_counts map)
│ ├── dev board (flat / swimlane by priority|assignee)
│ ├── qa / documenter / pm boards (flat, status-filtered; qa excludes VERIFYING; documenter scoped to task_type=documentation)
│ ├── main-pm board (cross-cell swimlane / flat team columns + Coordination column)
│ ├── board roadmap (P0/P1)
│ └── board stats
├── company_goals.py — CompanyGoalsService (singleton SINGLETON_ID)
│ ├── get (empty defaults if unset)
│ └── upsert (partial-key)
├── strategy_engine.py — StrategyEngine (dormant)
│ ├── assess (idle + stranded_blocked observations)
│ └── run_cycle (flag-gated, notify-only)
├── research.py
│ ├── SearchProvider ABC + Tavily/Brave/Exa/Null adapters
│ ├── ResearchService (clamping entry point)
│ ├── build_provider / get_research_service
│ └── helpers (_as_float / _truncated_fetch)
├── research_quota.py — ResearchQuotaTracker (Redis, fail-open)
│ └── check_and_consume (INCR-then-compare)
├── pitch.py — PitchService
│ ├── CRUD + list_pitches
│ ├── reject / approve
│ ├── _provision_repos (idempotent on re-approval)
│ ├── _register_topology (Product vs seed-project)
│ └── _seed_main_pm_task (PENDING Main-PM task)
├── github_provisioning.py — GitHubProvisioningService
│ ├── enabled (master+token+org)
│ └── create_repo (POST /orgs/{org}/repos, auto_init)
├── roadmap_engine.py — RoadmapEngine (dormant, roadmap_engine_enabled)
│ └── run_cycle (one held exploration task for the Product Owner; one-open-cycle dedup)
└── roadmap_service.py — RoadmapService
├── list_open_cycles
├── approve_item (materialize BACKLOG task, idempotent)
├── reject_item (record reason, idempotent)
└── _maybe_complete_cycle (completes exploration task once all items terminal)
Dependencies
Internal (roboco):
roboco.config.settings— all flags/caps (every file).roboco.services.base.BaseService—Project/Product/Kanban/CompanyGoals/Strategy/Pitch(log + session).roboco.db.tables—ProjectTable,ProductTable,ProductProjectTable,TaskTable,AgentTable,CompanyGoalsTable,PitchTable,WorkSessionTable.roboco.models.*—ProjectCreate/Update,ProductCreate/Update/ProductCellMapping,TaskCreateRequest,PitchCreate/PitchStatus,base(Team/TaskStatus/TaskType/Complexity/TaskNature),kanban.roboco.foundation.identity.Team(product),roboco.foundation.policy…indirectly via task.roboco.utils.crypto—encrypt_token/decrypt_token/EncryptionError(project).roboco.utils.converters—require_uuid/to_python_uuid(kanban, pitch).roboco.services.conventions— lazy-imported inProjectService._maybe_scaffold_conventions.roboco.services.work_session— lazy inProjectService.delete.roboco.services.workspace— lazy inProjectService.delete(delete_workspaces).roboco.services.task—StrategyEngine(list_in_progress_or_claimed,list_long_running_blocked),PitchService._seed_main_pm_task;RoadmapEngine/RoadmapService(ROADMAP_SOURCE/ROADMAP_ITEM_SOURCE,list_open_roadmap_cycles,TaskCreateRequest).roboco.services.prompter—RoadmapService._materializelazy-importsget_prompter_service(create_task_from_draft, the same confirmed-by-CEO-approval path pitch items use).roboco.foundation.policy.content.markers—RoadmapService/api/routes/roadmap.py(get_roadmap_cycle/set_roadmap_cycle, the cycle payload persisted onorchestration_markers).roboco.foundation.identity—RoadmapEngine._originate(AGENTS["product-owner"]/AGENTS["system"]).roboco.services.agent—PitchService(get_by_slug("main-pm")).roboco.services.notification—StrategyEngine.run_cycle.roboco.services.github_provisioning—PitchService.approve.roboco.services.project/product—PitchService.roboco.runtime.orchestrator— runs_strategy_engine_loop+_roadmap_engine_loop/_dispatch_roadmap_exploration; mountsroboco-searchMCP whenresearch_enabled.
External:
sqlalchemy(async ext) — all DB-backed services.httpx— research providers + GitHub provisioning (async client, owned-or-injected).redis.asyncio—ResearchQuotaTracker.pydantic(via models) —ProjectCreate/Updateetc.- Provider HTTP APIs: Tavily, Brave Search, Exa, GitHub REST.
Entry Points
- Routes (
roboco/api/routes/):project.py— Project CRUD endpoints →get_project_service.product.py— Product CRUD endpoints →get_product_service.kanban.py— 7 board endpoints (dev/qa/documenter/pm/main-pm/main-pm-flat/board + stats) →get_kanban_service.company_goals.py—GET /api/company-goals,PUT /api/company-goals(CEO) →get_company_goals_service.pitch.py— pitch CRUD +POST /api/pitch/{id}/approve(CEO) +/reject→get_pitch_service.research.py—POST /api/research/search,/fetch→get_research_service+ module-levelResearchQuotaTracker.prompter_live.py—get_project_servicefor project lookup during intake.dashboard.py—get_product_service/get_project_servicefor dashboard views.roadmap.py—GET /api/roadmap/cycles,POST /cycles/{id}/items/{id}/{approve,reject}(CEO-only) →get_roadmap_service.
- Orchestrator loop tick:
_strategy_engine_loop(orchestrator.py:6360) — created atstart()(line 1010), cancelled in shutdown (line 1075); ticks everystrategy_engine_interval_seconds, callsStrategyEngine.run_cycle._roadmap_engine_loop(orchestrator.py:7462) — same lifecycle shape, ticks everyroadmap_interval_seconds(default weekly), callsRoadmapEngine.run_cycle;_dispatch_roadmap_exploration(orchestrator.py:10284) spawns the Product Owner once per open exploration task. - MCP mount (orchestrator spawn):
roboco-searchMCP mounted into Board/PM agent containers only whenresearch_enabled(orchestrator.py:2914); the MCP server calls the/api/research/*routes. - Service-to-service:
ProjectServicecalled byWorkspaceService,GitService,PitchService,task,docs,cockpit,secretary, gateway choreographer;ProductService.project_forcalled from gateway delegate path;CompanyGoalsService.getcalled by briefing injector. - No CLI / lifespan entry points for this slice.
Config Flags
| Flag | Default | File:Line | Effect |
|---|---|---|---|
ROBOCO_CONVENTIONS_ENABLED |
False |
config.py:227 | ProjectService.create best-effort scaffolds .roboco/conventions.yml PR; inert when off |
ROBOCO_RESEARCH_ENABLED |
True |
config.py:245 | Mounts roboco-search MCP into Board/PM containers (orchestrator:2914) |
ROBOCO_RESEARCH_PROVIDER |
tavily |
config.py:252 | tavily/brave/exa/null adapter selection |
ROBOCO_RESEARCH_API_KEY |
None |
config.py:262 | Server-side only; unset → NullProvider |
ROBOCO_RESEARCH_MAX_RESULTS |
5 |
config.py:269 | Hard cap on web_search results (1–20) |
ROBOCO_RESEARCH_FETCH_MAX_CHARS |
20000 |
config.py:275 | Hard cap on web_fetch extracted chars |
ROBOCO_RESEARCH_TIMEOUT_SECONDS |
15.0 |
config.py:280 | Outbound provider HTTP timeout |
ROBOCO_RESEARCH_DAILY_QUOTA_PER_AGENT |
50 |
config.py:286 | Per-agent/day call ceiling (Redis, fail-open) |
ROBOCO_PROVISIONING_ENABLED |
True |
config.py:309 | Pitch auto-provisioning master switch (still inert without token+org) |
ROBOCO_PROVISIONING_TOKEN |
"" |
config.py:316 | GitHub PAT for repo creation (server-side only) |
ROBOCO_PROVISIONING_ORG |
"" |
config.py:323 | GitHub org where repos are provisioned |
ROBOCO_GITHUB_API_BASE_URL |
https://api.github.com |
config.py:327 | Override for GitHub Enterprise |
ROBOCO_PROVISIONING_TIMEOUT_SECONDS |
30.0 |
config.py:331 | Outbound GitHub provisioning timeout |
ROBOCO_PROVISIONING_REPO_PRIVATE |
True |
config.py:336 | Whether provisioned repos are private |
ROBOCO_STRATEGY_ENGINE_ENABLED |
False |
config.py:348 | Master switch — loop never starts when off |
ROBOCO_STRATEGY_ENGINE_INTERVAL_SECONDS |
1800 |
config.py:354 | Seconds between strategy assessment passes |
ROBOCO_STRATEGY_STRANDED_BLOCKED_MINUTES |
120 |
config.py:360 | Blocked-task threshold for "stranded" observation |
ROBOCO_PROTECTED_GIT_URLS |
[] |
config.py:770 | Denylist — ProjectService rejects git_url containing any entry |
ROBOCO_ENCRYPTION_KEY |
"" |
config.py:295 | Fernet key for git-token encrypt/decrypt |
ROBOCO_ROADMAP_ENGINE_ENABLED |
False |
config.py:865 | Master switch — _roadmap_engine_loop never opens an exploration cycle when off |
ROBOCO_ROADMAP_INTERVAL_SECONDS |
604800 |
config.py:875 | Seconds between roadmap-exploration cycles (default weekly) |
ROBOCO_ROADMAP_MIN_ITEMS_PER_CYCLE |
3 |
config.py:880 | Minimum item drafts propose_roadmap must submit for a themed cycle |
ROBOCO_ROADMAP_MAX_ITEMS_PER_CYCLE |
7 |
config.py:885 | Maximum item drafts per cycle |
Gotchas
Pitch partial-failure orphans GitHub repos— RESOLVED (536bbb64).GitHubProvisioningService.create_reponow treats a GitHub 422 "name already exists" response as an idempotent signal: it calls_fetch_existing_repoand returns the existing repo'sProvisionedRepoinstead of erroring. Combined with the Project-by-slug and Product-by-slug reuse already in place, re-approval is now idempotent end-to-end — no manual intervention needed. The initial partial failure still leaves an orphaned GitHub repo, but the re-approval path recovers it automatically.ResearchQuotaTrackerINCRs before the limit check (research_quota.py:65). An over-limit call still increments the counter (documented as fine for a ceiling). It also fails open on any Redis error (allowed=True) — research must not break because the cache is down. The route-level_quota_trackeris a module-level singleton sharing one Redis connection across requests.ProductService._replace_cellsflushes DELETEs before INSERTs (product.py:143). This is load-bearing: SQLAlchemy otherwise orders INSERTs before DELETEs for the same table, which would collide the new(product_id, team)rows with not-yet-deleted old ones onuq_product_projects_product_teamand 409 on any re-mapping of a team. Refactoring away the intermediate flush reintroduces the 409.— FIXED (KanbanService._task_to_cardhardcodessubtask_count = 0c71f9b3b/536bbb64). The new_load_subtask_countsmethod (kanban.py:47) batch-counts direct children per parent in a single grouped SQL query and passes the result map into each_task_to_cardcall;has_subtasksandsubtask_countnow reflect real data.— FIXED (KanbanService.get_main_pm_board_flatdrops non-backend/frontend/ux_ui tasks silently536bbb64/b3558d4e). A "Coordination" column (kanban.py:480) now catches non-cell-team tasks (Main PM, Board, fullstack, system, …). The column routing uses a dict-dispatch (status-key wins over team-key, fallback"coordination") so no card is built and discarded.ProjectService.deleteis gated by DB RESTRICT on tasks (project.py:282). Callers must cancel tasks first or the DB raises IntegrityError (route maps to 409). Active work sessions are abandoned first;delete_workspaces=Truedoesshutil.rmtreeon resolved paths — destructive, opt-in, best-effort.— FIXED (ProjectService.updateskips None-set fields536bbb64).git_tokensemantics unchanged (empty string clears,Noneleaves unchanged). All other fields now usemodel_dump(exclude_unset=True, exclude={"git_token"})—exclude_none=Truewas removed (#197), so a field the caller explicitly sets toNonenow clears the stored value instead of being silently skipped.- Strategy loop sleeps a full interval before the first cycle (orchestrator.py:6375).
await asyncio.sleep(interval)runs before the firstrun_cycle, so on startup there is a guaranteedstrategy_engine_interval_secondsdelay before the first assessment. StrategyEngine.run_cyclecatches nothing itself; the orchestrator wraps each cycle inexcept Exception(orchestrator.py:6380). A failingassessis logged and retried forever on the next tick — the CEO is never notified that the engine itself is broken.build_providerreturnsNullProviderfor an unknown provider name (research.py:326- 328). A typo inROBOCO_RESEARCH_PROVIDER(validated by pydantic pattern, so unlikely) would silently degrade to empty results rather than erroring.GitHubProvisioningService.enabledrequires master + token + org (github_provisioning.py:64).provisioning_enableddefaultsTrue, so the flag alone is not enough — an operator who toggles the flag without setting token/org still getsenabled=FalseandapproveraisesProvisioningDisabledError.PitchService._seed_main_pm_taskrequires amain-pmagent row (pitch.py:241-243). If the agent slug is missing it raisesValidationErrorafter provisioning has already happened — another partial-failure window (repos + Product created, no seed task).
Drift from CLAUDE.md
- Research provider set vs CLAUDE.md. CLAUDE.md's "Technology Stack" / feature-flags section lists web research under
ROBOCO_RESEARCH_ENABLEDonly; it does not enumerate the provider adapters (tavily/brave/exa/null) or the per-agent daily quota (ROBOCO_RESEARCH_DAILY_QUOTA_PER_AGENT, default 50). Code: config.py:252/286, research.py:310. Not a contradiction — an omission in the doc. - CLAUDE.md says the strategy engine "never spends, builds, or auto-approves" and is default-OFF. Code matches exactly (
strategy_engine_enableddefaultFalse, config.py:348;run_cyclenotify-only, strategy_engine.py:92-106). No drift. - CLAUDE.md says pitch provisioning is gated by
ROBOCO_PROVISIONING_*. Code matches (provisioning_enabled+_token+_org+_repo_private+_timeout_seconds, config.py:309-336;GitHubProvisioningService.enabledrequires all three, github_provisioning.py:64). No drift. - CLAUDE.md does not mention
ProductService.project_foras the per-cell routing keystone, though it does describe product cell-routing as a feature. Code: product.py:87, called from the gateway delegate path. Doc omission, not contradiction. - CLAUDE.md does not mention
ROBOCO_PROTECTED_GIT_URLS(the project denylist, config.py:770, project.py:40). Doc omission. - CLAUDE.md's service table does not list
KanbanService,CompanyGoalsService,StrategyEngine,ResearchService,PitchService,GitHubProvisioningService. The CLAUDE.md "Services" table is explicitly a non-exhaustive "Core services" list, so this is an acknowledged omission rather than drift. - No contradictions between CLAUDE.md claims and actual code were found in this slice. All documented flags, defaults, and behaviors (default-off strategy engine, server-side- only keys, notify-only engine, pitch→provision→normal-lifecycle, CEO-only approve) match the code.
Changes Since Baseline
git log --oneline fd10cc862c2020b3f639cdb686d427b0198a2441..HEAD -- <slice files> and git diff --stat for the nine in-scope files both return empty — no commit between the baseline (fd10cc86 "Update ci.yml") and HEAD (3aff6e04 "Chore: Close gaps (#285)") touched any file in this slice. The two commits ahead of baseline (15effce0 "141 Gaps fill-in (#283)", 3aff6e04 "Chore: Close gaps (#285)") modified other files only.
No logic-touching commits to list. Impact: none — this slice is byte-for-byte unchanged since the baseline.
Post-snapshot updates (since 2026-06-29): three commits landed on this slice's files.
536bbb64(Chore/all/logical gaps sweep #286, 2026-06-30):github_provisioning.py— added_GITHUB_REPO_EXISTS_STATUS = 422sentinel and_fetch_existing_repomethod;create_reponow handles 422 "already exists" idempotently, resolving the orphaned-repo partial-failure risk (#83/#84).pitch.pydocstring updated to reflect new idempotency guarantee.project.pyupdate()— removedexclude_none=Truefrommodel_dumpso explicit-None fields now clear stored values (#197).c71f9b3b([chore] logical-gaps: kanban board column coverage + status-class fixes, 2026-06-30):kanban.py— added_load_subtask_countsbatch query;_task_to_cardnow takes asubtask_countsdict and populates real subtask counts (#198). Added "Other" fallback column in_build_columnsto prevent any task-card from being built-then-discarded.get_qa_board: removedVERIFYINGfrom QA statuses (dev self-verification, not a QA state).get_documenter_board: addedtask_type == DOCUMENTATIONfilter.get_main_pm_board_flat: broadened status filter to includePENDING/CLAIMED/COMPLETEDand added proper column routing (incoming/distributed/done). Added "Coordination" column for non-cell-team tasks (#196).b3558d4e([chore] complexity: split 5 C-rank blocks to <=B, 2026-06-30):kanban.pyget_main_pm_board_flat— refactored if/elif routing to a dict-dispatch (status_col+team_colmaps) for xenon complexity gate; no functional change.
Regression Risks
No commit since fd10cc86 modified any file in this slice, so there are no recent-change regressions to flag. The table below lists standing structural risks already present in the code (not introduced by recent changes) that a future change in this slice or a caller could trip.
| Title | File:Line | Claim | Severity |
|---|---|---|---|
536bbb64 |
pitch.py / github_provisioning.py | create_repo now handles GitHub 422 "already exists" by fetching the existing repo; re-approval is idempotent end-to-end. Initial partial failure still orphans the repo on GitHub, but re-approval recovers it automatically. |
|
| Seed-task failure after provisioning | pitch.py:241-243 | _seed_main_pm_task raises ValidationError if main-pm agent is missing — after repos + Product are already created. Another partial-failure window with no rollback. |
medium |
| Strategy engine failure is silent | orchestrator.py:6380, strategy_engine.py:92 | A failing assess is caught by the orchestrator's broad except Exception, logged, and retried next tick; the CEO is never notified that the engine is broken — looks dormant while actually erroring. |
low |
| Quota INCR-then-compare + fail-open | research_quota.py:51-73 | Over-limit calls still bump the counter (documented); Redis outage fails open (allowed=True), so a quota bypass during a Redis outage is by design. |
low |
_replace_cells flush ordering is load-bearing |
product.py:135-143 | The intermediate flush() (DELETEs before INSERTs) prevents a 409 on uq_product_projects_product_team. Refactoring it away reintroduces the unique-constraint collision on any team re-mapping. |
low |
| kanban.py | Added "Coordination" column; dict-dispatch routing ensures all tasks are placed. | ||
ProjectService.update skips None-set fields536bbb64 |
project.py | exclude_none=True removed from model_dump; explicit-None fields now clear stored values (#197). |
|
subtask_count always 0 in kanban cards |
kanban.py | _load_subtask_counts batch-loads real child counts; _task_to_card uses them. |
Health
This slice is internally coherent and consistent with CLAUDE.md: every documented flag, default, and behavior matches the code, and the two slices-of-flow (CEO-driven pitch origination into the normal lifecycle; dormant notify-only strategy watcher) are cleanly separated and default-safe. The services follow a uniform BaseService + session-bound factory pattern, provider/research quotas fail open where cost-control (not security) is the goal, and the provisioning path is inert without token+org. The pitch approval path's external-side-effect non-atomicity remains (GitHub repo creation cannot roll back with the DB transaction), but re-approval is now idempotent end-to-end: create_repo handles GitHub 422 "already exists" by fetching the existing repo, and Project/Product rows are reused by slug, so a CEO re-approving after a partial failure recovers cleanly. The remaining open risk is _seed_main_pm_task failing after repos are already created (missing main-pm agent row). Post-snapshot three commits updated this slice's files, resolving four standing risks (kanban subtask counts, flat-board dropped cards, project.update None-field skip, and the pitch re-approval collision).