Plan Review¶
The plan-review subsystem turns the decomposed breakdown of an objective from a
transient value into a durable, reviewable, editable first-class entity. When the
plan-approval gate is enabled, splittable team work is decomposed into a Plan,
persisted, and parked for a human decision before any team builds. An operator can
read, rework, or send the plan back for changes through the /plans API and the
Plan Review workspace, then approve or reject it through the existing /approvals
decision path.
Plan Review is the single review surface for shaping an initiative: a request yields one plan reviewed as a whole, never a scatter of per-item approvals. This is the sole reason approval-gating defaults on (see Conversational entry); mid-build implementation forks are a separate, narrow surface documented in agent-execution.md.
Durable Plan Entity¶
Plan (core/plan.py) is the first-class replacement for a plan that previously
lived only as a DecompositionResult serialised into an approval's metadata. It is
persisted, versioned, and revisable, and outlives the approval decision: the approval
carries only the plan's plan_id.
Plan:id(UUID),project,objective_id,objective_title(denormalised at creation so the surface never resolves, or falls back to, a raw id),parent_task_id,items(ordered tuple forming a dependency DAG),task_structure,coordination_topology,status,failure_reason(why aFAILEDplan failed,Noneotherwise),forecast_id,review(the consolidated stakeholder-panel review, orNone),open_questionsandassumptions(what the planner surfaced for the human),objective_criteria(the objective's acceptance criteria, denormalised for the coverage map),version_history(snapshots of prior submitted versions),version,created_at,updated_at. A model validator rejects an empty item list for every status except thePLANNING/FAILEDshells (which may carry no items), duplicate item ids, an unresolvable dependency, or a dependency cycle (topological sort); a second validator tiesfailure_reasonto theFAILEDstatus (present iff FAILED). A malformed plan is caught at construction rather than as a dispatch failure.PlanItem:id(a canonical UUID string, because dispatch rebuilds each child task from it),title,description,dependencies,owner,acceptance_criteria,expected_artifacts,required_skills,required_tags,estimated_complexity,stakes,kind(WORKorDECISION),optionsandchosen_option_id(decision items), andsatisfies(the objective criteria this item advances). A validator rejects a non-UUID id, a self-dependency, or duplicate dependencies.
A plan must advance the objective it decomposes¶
satisfies exists so success-criteria coverage can be CHECKED, and for a while
nothing checked it. The three pieces disagreed: the prompt states the contract
("Between them, the items must cover every objective criterion"), the tool
schema leaves the field out of its required list, and the field's own
description invites omission per item ("Omit only for pure-support items that
advance no objective criterion directly"). A planner reading every item as pure
support therefore returned a plan tagged with nothing, which parsed cleanly,
read correctly, and answered "which of the objective's criteria does this plan
address?" with silence.
That is not hypothetical. A live decomposition of a 42-criterion objective came
back with satisfies=[] on all seven subtasks, on the same specification where
an earlier run of the same planner tagged all seven. Variance rather than
inability, which is worse: it poisons an unpredictable subset of plans instead
of failing consistently, and any consumer computing coverage over it reports a
confident zero. The recursion-depth sweep is one such consumer, and this is why
its survival ratio could not be measured against leaf work at all: too many
plans claimed nothing, whole cells divided by zero, and the metric had to fall
back to the specification's own requirement count.
llm_parse.py::_validate_coverage refuses a plan that advances NONE of the
objective's criteria, beside the roster and graph checks and for the same
reason they sit there: a DecompositionError raised at parse time is
correctable in-session, so the agent-session planner's submit tool turns it into
a tool error the agent fixes on its next turn, where the same fault found at
dispatch has already been approved by an operator who was told nothing was
wrong.
Two boundaries of the rule are deliberate. It is checked at PLAN level rather than per item, because the field's own semantics allow a genuine pure-support item to claim nothing; what cannot hold is that every item is pure support, since then nothing builds the objective. And it is FULL coverage that is documented but not enforced: partial coverage stays a plan worth having, while zero coverage is the degenerate case with no reading at all. Enforcing every criterion would put a rule the planner keeps re-breaking in front of the retry ladder, which is how the em-dash style rule once took 18 of 25 planning calls.
The objective's criteria reach the parser the same way available_roles does,
threaded from the two call sites that hold the parent task, and an empty tuple
skips the check: an objective declaring no criteria has no coverage to claim.
Open questions are asked, not filed¶
open_questions is what the planner could not resolve on its own, and for a
while it was written to the plan and read by nothing: the org asked two good
questions, GET /meta/chat/questions returned [], and the escalation fired
into a void. Asking the human is the single most-wanted behaviour on the
product's own goal list, so a question that reaches no human is not a record,
it is a loss.
Each open question is now filed as a parked question alongside the plan
approval, inside the same guard, with action_type=CLARIFY_ACTION_TYPE, the
plan's parent task, and metadata carrying the plan id. That puts it on the
surface that already exists: it appears in GET /meta/chat/questions and is
answered or declined through the same narrow door as every agent-raised
question, with no second decision path.
The approval carries no question index. An index identifies a position in
open_questions, and the first answer rewrites that tuple, so every remaining
approval's index would point at a different question than the one its own text
asks; the question text on the approval is the identity, and settling removes
one matching entry rather than every duplicate.
The answer goes back onto the plan the agents execute: answering removes the
entry from plan.open_questions and appends the answer to plan.assumptions
through PlanService, under a compare-and-set retry so two answers landing at
once cannot lose one of the write-backs; declining keeps the existing
declined-question note.
That write-back is a fast path, not what makes the answer stick. It runs after
the decision is already durable on the approval, so a persistence failure there
would leave the plan asking something the operator answered, and the endpoint
cannot report the decision as failed when it demonstrably happened. The decided
approvals are the record, so replay_decided_questions reconciles the plan
against them before dispatch rebuilds anything from it. The reconciliation
counts rather than matches: a plan should still list one occurrence of a
question per approval for that text still PENDING, so the surplus above that
count is exactly what the plan has not heard. That keeps a repeat pass a no-op
and stops one answer settling the occurrence that belongs to a second,
identically worded question. An EXPIRED approval is not a decision and is
never replayed: nobody answered it, so its occurrence stays open.
Question text is persisted raw and fenced with wrap_untrusted(TAG_TASK_DATA,
...) only at the LLM prompt boundary, per SEC-1.
Decision items¶
A PlanItem with kind = DECISION is a real choice the plan hinges on (stack,
architecture) rather than a unit of work. It carries options (at least two
PlanOptions, exactly one recommended, unique ids) and an optional
chosen_option_id; a WORK item carries neither (validate_decision_options).
The generator emits a decision where a genuine choice exists; the reviewer records
the pick on the review workspace, and PlanItem.resolved_option() resolves it to
the chosen option or, absent a pick, the recommended one. Decision items are not
executed: decomposition_from_plan strips them from the dispatchable tree (and
from remaining items' dependencies), and on approval each resolved decision is
recorded into the project brain as a first-class DECISION entry
(api/controllers/_plan_decision_record.py) so the company's shaping choices
survive rather than vanishing.
Stakeholder review panel¶
Before the plan is parked for the human, a bounded panel of stakeholder agents
reviews it (engine/plan_review/). select_review_panel seats the relevant leads
(CTO, CFO, department heads for the domains touched, a senior peer), sized to the
plan and excluding the owner (no self-review). Each panellist runs a bounded
persona session (AgentSessionPlanReviewPanel) and submits a structured verdict
(ENDORSED / CONCERNS / REVISION_REQUESTED) with categorised findings; a
deterministic synthesis (synthesise_review) consolidates them onto Plan.review
(overall verdict = the most severe). The panel is wired at startup without failing
boot, and runs as a distinct pipeline phase between decompose and the human gate.
A finding sends the plan back to be re-planned. The panel exists to catch a
plan before the operator has to, so its verdict drives another planning pass
rather than riding along as commentary: build_reviewed_plan
(engine/pipeline/plan_revision.py) re-decomposes against a brief carrying every
finding, then re-reviews, until the panel stops objecting or
coordination.plan_review_max_revision_rounds is spent. Any finding counts,
whatever verdict carries it, since an endorsement that still notes a gap has
still noted one. Each round is its own plan_review_panel_revision_N phase, so
how many panels ran is readable rather than inferred, and each round briefs from
the ORIGINAL objective plus the MOST RECENT review: findings describe the plan
they were raised against, so carrying a superseded round's forward asks the planner
to fix a plan that no longer exists. Reaching the cap is not a failure. The plan
is parked for the operator carrying whatever is still outstanding, which is
exactly what happened before anything read the findings, and
pipeline.plan_review.revision_exhausted says so. Setting the cap to 0 keeps
that older behaviour deliberately: the panel still reviews and its findings
still reach the plan and the operator, but nothing acts on them.
The finding vocabulary answers the questions the brief asks.
PlanReviewFindingCategory names the kinds a reviewer produces: GAP,
MISSING_OWNER, MISCALIBRATED_STAKES, RISKY_DECISION, BUDGET_CONCERN,
SEQUENCING, UNVERIFIABLE_CRITERIA, OVERSIZED_SCOPE, and OTHER last. It is
sized to the brief rather than guessed: the brief poses a question per kind, and
three of them had no category to land in, so reviewers proposed one the enum
could not express, were rejected, and resubmitted under a worse one, at a turn
per reviewer per panel. SEQUENCING is the one with recorded live evidence, a
plan of six items with zero dependency edges and an item naming three it declared
no dependency on: a claim about the graph, not about any single item, which GAP
reads as a missing item and OTHER discards the kind of entirely.
The vocabulary has one owner. CATEGORY_GUIDANCE in
engine/plan_review/review_tool.py maps each kind to its meaning, and both the
submit_plan_review tool schema and the reviewer brief render from it, so a
category the brief asks about cannot be one the schema omits. A member with no
entry fails render_category_guidance rather than reaching a reviewer as a bare
name it would then reinterpret. OTHER stays reachable, and a finding landing
there is worth reading as a signal about the enum rather than a routine outcome.
The category is persisted inside the Plan.review JSON document (plans.review,
TEXT on SQLite and JSONB on Postgres) rather than in a constrained column, so
widening the vocabulary ships no migration; the generated dashboard enums do
follow, via scripts/generate_dto_types_ts.py.
An absent review says why it is absent. review = None used to mean three
different things at once, and the operator saw the same empty
evidence_package for all of them. The session now returns a
PlanReviewOutcome carrying either a review or the reason there is none, and
the reason is persisted on Plan.review_absent_reason, surfaced in the
approval payload and shown as a blocking banner on the dashboard gate:
| outcome | what the operator is told |
|---|---|
| a seated panel produced verdicts | the review, as before |
| no panel is attached | no panel was seated for this plan; the plan carries zero quality signal |
| a seated panel produced no verdict | the panel ran and returned nothing; the plan carries zero quality signal |
| every seated reviewer failed on a provider error | not a review outcome at all: plan preparation FAILS (FAILED plan + FAILED task) rather than presenting an unreviewed plan as merely unreviewed |
The last row is the load-bearing one. A provider outage during review is an outage, and parking the plan for approval turns it into a human rubber-stamp on a plan nothing checked.
"No verdict" is reached only after one correction. A panellist holds exactly
one tool, submit_plan_review, so a session that answered in prose has not
abstained: it did the wrong thing. The session pushes back once, saying prose
is not a verdict and naming the tool, and only a second non-submission is
recorded as an absent opinion. Recording the first as absent sends the plan to
its human gate with no quality signal, and every panellist fails that way at
once, so the panel abstains unanimously for a reason no reviewer chose.
request_plan_approval takes the outcome as a required argument, on both
the port and the gate: an optional one with a None default reintroduces the
blank state the type exists to forbid, one caller at a time. The PLANNING shell
opened before decomposition says so explicitly rather than leaving the field
empty, and its provenance is replaced wholesale when the filled plan is parked.
Lifecycle (PlanStatus)¶
stateDiagram-v2
[*] --> PLANNING
PLANNING --> PENDING_REVIEW: decomposition fills the shell
PLANNING --> DRAFT: operator edits the shell
PLANNING --> FAILED: decomposition failed / empty
PLANNING --> SUPERSEDED: superseded by a re-plan
DRAFT --> PENDING_REVIEW
DRAFT --> FAILED: could not be delivered
PENDING_REVIEW --> APPROVED
PENDING_REVIEW --> REJECTED
PENDING_REVIEW --> FAILED: approval-park failed
PENDING_REVIEW --> PENDING_REVIEW: edit / request-changes (new revision)
DRAFT --> SUPERSEDED: superseded by a re-plan
PENDING_REVIEW --> SUPERSEDED: superseded by a re-plan
APPROVED --> EXECUTING: dispatched
APPROVED --> FAILED: dispatch precondition failed
APPROVED --> SUPERSEDED: superseded by a re-plan
EXECUTING --> INTEGRATING: every item done
EXECUTING --> FAILED: dispatch failed
EXECUTING --> SUPERSEDED: superseded by a re-plan
INTEGRATING --> EVALUATING: assembly job passed its review gate
INTEGRATING --> EXECUTING: an item regressed
INTEGRATING --> SUPERSEDED: superseded by a re-plan
INTEGRATING --> FAILED: assembly will not assemble
EVALUATING --> COMPLETED: every success criterion met
EVALUATING --> EXECUTING: an item regressed
EVALUATING --> SUPERSEDED: superseded by a re-plan
EVALUATING --> FAILED: the judgement cannot run
COMPLETED --> [*]
REJECTED --> [*]
FAILED --> [*]
SUPERSEDED --> [*]
Plan-first-from-greenlight. When a splittable initiative is greenlit, a
PLANNING shell (no items yet) is persisted before decomposition runs, so
every greenlit objective leaves a first-class, visible plan even if decomposition
never completes. Decomposition fills the shell in place (moving it to
PENDING_REVIEW); a decomposition that fails or produces no items transitions the
shell to FAILED, carrying a failure_reason the review surface shows, rather
than leaving a silent orphan task. A plan the planner built over the request's
max_subtasks takes the same route: the reason names the produced count and
the limit, so the operator can raise the ceiling or narrow the objective instead
of silently receiving a thinner plan. A plan can also reach FAILED after
decomposition succeeded, if parking the approval fails: it is then FAILED with its
items intact, so FAILED permits (but does not require) an empty item list.
FAILED therefore means "could not be delivered", not the narrower "never
reached a review decision". Four routes land here: decomposition, the approval
park, dispatch, and a project teardown over a plan with no items (superseding an
itemless plan is what the items CHECK forbids, so the cascade fails it with
"project deleted" instead).
Dispatch reaches FAILED from either side of one line. An approved plan is
moved to EXECUTING before coordinate(...) runs (load-bearing ordering, so
the rollup never encounters a PLANNING project with tasks running), so a raise from
coordinate fails an EXECUTING plan, while the precondition branches that
return before it (no coordinator, no parent task, a project that cannot be
linked) fail an APPROVED one. Both carry the redacted cause, so a plan never
sits dispatched with no children and no explanation.
The PLANNING and FAILED statuses are the only ones permitted to carry an empty item
list (enforced by the model validator and the SQLite / Postgres items CHECK);
every other status requires a non-empty, validated item DAG. A failure_reason is
present iff the status is FAILED (a cross-field model validator enforces both
directions).
An edit or request-changes is accepted only from a reworkable status.
DRAFT and PENDING_REVIEW are the reworkable statuses; PLANNING is a transient
shell (not operator-reworkable); COMPLETED, REJECTED, SUPERSEDED, and FAILED
are terminal. An operator rework or request-changes is accepted only from a
reworkable status, so a decided or failed plan cannot be revived (a retry is a
fresh run). Each edit bumps version, and every write is version-guarded
(optimistic concurrency): a stale writer is rejected with a conflict rather than
silently clobbering a concurrent edit.
Both land the plan back in PENDING_REVIEW carrying a new revision, because
both produce revised items: nothing is dispatched from a reworkable status, so
there is no running work to retire and no successor to point a project at. A
change request that parked the plan in DRAFT instead is what left one sitting
with nobody assigned to revise it, since the org has no trigger on DRAFT and
the operator's only remaining route was to hand-author the item list through
/plans/{id}/replan. A snapshot of the prior revision reaches version_history
either way, so a reviewer can diff what changed, and Plan.review is cleared:
the panel's findings referenced items that no longer exist.
Premises travel with the items that rest on them. assumptions and
open_questions belong to the pass that derived them, so a re-plan passes its
own (PlanPremises) and an operator hand-edit does not: the operator revised
the work, not the premises, and has nothing fresh to supply. Getting this
backwards is not cosmetic. A live re-plan replaced all ten items with "build
the engine from scratch" while the plan went on asserting the engine already
existed, because the rework carried the superseded premises forward. The plan
contradicted itself, and the false assumption the operator had just refuted was
the one left standing.
Approval is not the end of the plan's life. APPROVED dispatches the plan and
hands it to EXECUTING, where its items' tasks are in flight. Every item being
done opens the tail rather than completing the plan: INTEGRATING assembles the
verified pieces into one running deliverable, EVALUATING scores that whole
against the objective's success criteria, and only then is COMPLETED reachable.
There is no EXECUTING -> COMPLETED edge, which is what stops the tail from
being skipped. These transitions are driven by the initiative rollup
rather than by an operator, and the whole table is enforced by a state machine
(core/plan_transitions.py) that every status write funnels through. See
Initiative tail for the two tail stages and
Project lifecycle for how completion composes with the
verify gate.
Persistence¶
PlanRepository (persistence/plan_protocol.py) composes the ADR-0001 generics
IdKeyedRepository[Plan, NotBlankStr] + FilteredQueryRepository[Plan,
PlanFilterSpec]; the SQLite and Postgres implementations are kept in parity. The
plans table stores items as JSON (a non-empty array for every status except
the PLANNING / FAILED shells, which may carry no items, CHECK-enforced), the
nullable failure_reason (non-blank when present, CHECK-enforced), and
review / open_questions / assumptions / objective_criteria /
version_history as JSON columns; Postgres uses TIMESTAMPTZ for the timestamps
and a composite (project, status, id) index for the combined-filter list query.
update() takes an expected_version guard and raises
PersistenceVersionConflictError when the stored version has moved.
plans.parent_task_id is a real REFERENCES tasks (id) ON DELETE RESTRICT,
indexed (parent_task_id, id) for the equality-then-ordering shape the delete
guard queries with. RESTRICT rather than CASCADE because a plan is a reviewed
decision record with its own delivery verdicts hanging off it: destroying that
as a side effect of removing a task is a decision an operator should make
deliberately, and DELETE /plans/{id} is where they make it. A task delete that
a plan references is refused with PLAN_PARENT_TASK_IN_USE (409) naming the
plan, in TaskEngine.delete_task so every caller inherits it, with the
constraint as the backstop for a race. plan_item_comments.plan_id is the
mirror-image case: a remark ON a plan means nothing without it, so it CASCADEs.
The reference cannot stop a task deleted mid-decomposition, so the approval gate
re-reads the parent before it fills the shell and parks the approval, raising
PLAN_PARENT_TASK_MISSING; the pipeline routes that through its compensation,
so the plan lands FAILED with the reason and never reaches PENDING_REVIEW
asking for a decision on work with no owner.
Deleting a plan under review¶
DELETE /plans/{id} and DELETE /tasks/{id} expire the pending approvals
that decide about the row before removing it, and the delete is
conditional on that succeeding. An approval is a question about something
that exists: once the row is gone the queue still offers approve and reject,
and answering drives the resume path at an id that resolves to nothing.
Expired rather than rejected, because a rejection is a reviewer's verdict and
nobody made one.
A decision that lands between the read and the write is not overwritten. The verdict was made while the row still existed and the resume path is acting on it, so the delete is refused with a 409 and the operator retries once the dispatch has settled; every approval the refused attempt had already expired is put back, so a refused delete leaves the queue as it found it. Two concurrent deletes of the same row do not conflict: an approval another delete already retired satisfies this one rather than blocking it.
Per-item discussion lives in a separate append-only store,
PlanItemCommentRepository (persistence/plan_comment_protocol.py, composing
AppendOnlyRepository), backed by the plan_item_comments table. Comments are
immutable and written independently of the version-guarded plan row, so posting a
comment never conflicts with a concurrent rework. Each comment carries an
author_kind (human/agent), the responding agent's id for an agent comment,
and a flat reply_to_id linking a reply to the message it answers: the item is
the thread, so a reply is a parent link, not a nested tree. This keeps the
append-only immutability (each comment and reply is its own row) while making a
comment reply-bearing and agent-answerable.
When a reply model is configured, a human comment is answered inline by the
responsible role: PlanItemReplyService (engine/plan_review/reply.py) resolves
the responder (the item's owner role if an active agent holds it, else the
Chief of Staff) and makes ONE grounded, fenced completion call (not a ReactLoop,
no tools) over the item's own text, then appends an attributed agent reply linked
to the operator's comment. It is loop-safe (only a human comment is answered,
so an agent reply never triggers another) and failure-isolated: the human
POST .../comments always returns 201 even if reply generation fails, and the
reply is gated live per comment by coordination.plan_review_reply_enabled
(opt-out, default on). Lightweight discussion never re-plans the plan; only
request-changes does that.
Owners come from the roster¶
Every plan item names an accountable owning role, and that role is the thing a dispatch looks up. A role nobody holds produces an item with nobody behind it, discovered at dispatch if at all, so the roster is bound at every level rather than trusted at one:
DecompositionContext.available_rolescarries the distinct roles behind the active agents (roster_from_agents), populated wherever a decomposition is started: the pipeline, the coordination and manual-decomposition endpoints, and the stalled-initiative replan.- The submit-plan tool schema puts an
enumonrequired_role, so a schema-enforcing provider cannot emit an unknown role at all, and the system prompt lists the roster in prose, because the enum only reaches a provider that enforces schemas. - Parse time rejects an unknown owner with a correctable
DecompositionErrornaming the offending role and the valid set, alongside the kind/artifact invariant. The planning session can resubmit inside the same session. PATCH /plans/{id}refuses an operator edit that owns an item to a role no agent holds, and the review surface flags such an owner as its own attention row rather than counting it under "all assigned".
An empty roster means "no roster known" and skips every check: an org with no agents has nothing to validate against, and failing there would block a greenlight for a reason unrelated to the plan.
The prompt deliberately names no example role. The one that used to sit in the tool schema was not in the shipped org template, and the planner reproduced it.
Decomposition Projection¶
engine/decomposition/plan_mapping.py projects both directions so the gate, the
API, and the resume path stay in step:
plan_from_decomposition()builds a durablePlanfrom an executedDecompositionResult(subtasks become plan items).decomposition_from_plan()rebuilds a dispatchableDecompositionResultfrom a (possibly operator-edited) durable plan, so the tree that builds on approval is exactly the plan under review. Each child task carries the item's acceptance criteria and expected artifacts, so the fail-loud zero-artifact guard engages on the plan-review dispatch path.
Conversational entry¶
A plan is stood up from the unified chat one way: the charter interview
(a /meta/chat/turn classified charter). It has a precondition an operator
must meet before any of this is reachable: charter.interview_model ships
blank, so the charter_engine subsystem stays down on an empty-company boot
and GET /subsystems reports it waiting on that setting. Naming a
provider-bound pair is what brings the interview up; charter_dispatch, which
owns the approve path, then activates once the work pipeline exists. Until
both are up there is no conversational route to an initiative at all.
The interview asks until it has
enough to draft a charter, the operator reviews and approves what it drafted,
and meta/charter/dispatch.py then builds the single WorkItem that carries
plan_required=True and the charter_id of the approval that authorised
it. Because plan_required forces a SPLITTABLE routing verdict into the
(default-on) gate, decomposition parks a PLAN_REVIEW approval carrying the
drafted plan, and the operator reviews that as a whole.
Deciding it has heard enough is itself a decision. The interview owns when
to stop asking, and stopping early costs a charter whose goals, success
criteria, scope, envelope and project were all supplied by the model and
rendered beside the one answer the operator actually gave, with nothing to tell
them apart. So a draft records which facets it supplied itself
(CharterFacet, assumed_facets on both CharterDraft and ProjectCharter,
persisted with the charter), and meta/charter/_facet_coverage.py presses once
before the draft is presented: here is what I would otherwise decide for you.
The provenance travels to the approval, because what the operator is approving
is a proposal from the organisation, not their own brief handed back.
Decomposition is held to three graph questions before a plan reaches review,
in core/plan_validation.py: a declared ordering that no edge expresses
(describe_structureless_graph), an item naming another it declares no
dependency on (describe_unstated_reference), and an item whose own
acceptance criteria name a file only a non-dependency produces
(describe_undecidable_criterion). The last of those is the one a reviewer
cannot resolve by trying harder: the gate is unjudgeable at the moment the
item is reviewed and stays unjudgeable through every rework, so it refuses for
as long as the plan stands. All three are asked at both boundaries a plan can
arrive through: the parse that reads a decomposed plan, and the edit or
replan payload an operator submits. A plan hand-authored into that shape is
no more judgeable than one a model wrote.
The propose capability cannot produce a plan and has no field in which to ask
for one (ProposeDecision is clarify-XOR-steer, extra="forbid"). It steers
work a charter already authorised; its directives park on their own confirmation
path. Committing the organisation to a body of effort and a budget is the
operator's decision, taken once, in the interview, and recorded by their
approval; it is never inferred from a message by a classifier.
That is held in two halves, because a claim and its truth are different
questions. Structurally, WorkItem refuses plan_required=True with no
charter_id, so no adapter can construct a brief that opens an initiative
without naming an approval. Substantively, the spine resolves that id against
the charter store on every plan-forcing brief (_require_authorised_initiative,
through the CharterAuthority port) and refuses anything that does not resolve
to an APPROVED charter, naming which of the two it was. With no store attached
it refuses as well: an authorisation nothing can check is not one.
The approval is therefore recorded on the charter before its dispatch runs,
which is also the honest order (the operator took the decision before any of it
ran), and task_id is stamped on afterwards as dispatch provenance. The window
between the two writes is a charter that is authorised with no run behind it;
approving again resumes the dispatch rather than reporting the charter decided.
API¶
PlanController (api/controllers/plans.py, path /plans) owns the plan-native
capabilities the approval flow lacks. Whole-plan approve/reject stay atomic on the
canonical /approvals/{id} path; because a plan review is decision-gathering with
its own surface, it is excluded from the generic Approvals inbox (a source filter
on GET /approvals) and gains its own red nav badge, and the operator approves or
rejects it inline on the Plan Review page (the toolbar resolves the plan's
parked approval from its plan_id metadata and drives the same /approvals path,
so approval stays atomic).
| Method | Path | Purpose |
|---|---|---|
GET |
/plans |
List plans (cursor pagination; status / project / objective_id filters) |
GET |
/plans/{id} |
Fetch a plan |
GET |
/plans/{id}/evaluation |
The evaluate stage's judgements, newest first (see Initiative Tail) |
GET |
/plans/{id}/transitions |
The plan's recorded status transitions, newest first: who asked, why, and from which version (see Initiative Tail) |
PATCH |
/plans/{id} |
Rework items (new revision, back to PENDING_REVIEW) |
DELETE |
/plans/{id} |
Remove a plan that is not a record of work. Always deletable while undispatched (PLANNING / DRAFT / PENDING_REVIEW / FAILED); a plan deletes only when it has zero live task rows, because "its items are building" is checked against the tasks rather than inferred from the status: a dispatch that died before writing a single row leaves nothing building. That check and the delete are ONE repository call in one transaction (delete_if_no_live_tasks), never a count followed by a delete: a task filed between the two would be stranded on a plan id that no longer resolves, and nothing would report it. A terminal plan is refused outright (its record and its delivery verdicts outlive it), and a genuinely building plan is refused naming the count (409). Expires the plan's parked PLAN_REVIEW approval FIRST, and deletes only if that lands: left pending, a reviewer could still approve it, and the resume path would then fail the parent task over a plan that no longer exists. A concurrent decision wins instead (409, nothing deleted), because the verdict was made while the plan still existed and the dispatch is already acting on it |
POST |
/plans/{id}/request-changes |
Re-plan against the operator's note. The org decomposes afresh from a brief the note leads and the plan's outstanding panel findings follow (_plan_rework.py), and the revised items replace the reviewed ones through the same validated path an edit takes, so the plan comes back under review carrying a new version rather than parked for a revision nobody performs. LLM-bound, like any other turn that asks the org to think. Refused rather than parked when it cannot be honoured: 503 when no planner or task engine is running, 409 when the objective task is gone, 422 when neither the note nor a finding says what should change. Every refusal lands before any write, so the operator's plan stays reviewable |
GET |
/plans/{id}/comments |
List a plan's comments oldest-first (optional item_id) |
POST |
/plans/{id}/comments/items/{item_id} |
Post a comment on an item (optional reply_to_id); a responsible role may answer inline |
PlanService (api/services/plan_service.py) owns the lifecycle transitions with
uniform API_PLAN_* audit logging, the terminal-status guard, version-conflict
translation, and the sync_status() used by the approval-resume path so the
decision transition gets the same audit coverage as an operator edit. On a rework
it snapshots the pre-edit version into version_history (bounded), so a reviewer
can diff how a revision addressed the panel's concerns. Edits and decisions publish
plan.updated / plan.changes_requested events (a delete publishes
plan.updated too, so an open list drops the row), and a posted comment publishes
plan.comment_added, all on the plans WebSocket channel. The event is a refresh
signal (its payload stays the minimal locator); a subscriber reloads the item's
thread, so an inline agent reply (broadcast the same way when it lands) surfaces
without a new channel or payload shape.
PlanReviewApprovalGate publishes the same plan.updated when it fills and
parks a plan, and when it marks one FAILED. Those writes happen on a background
spine, after the request that started them returned, so the gate is handed a
narrow publisher (PlanNotifier, built from the channels plugin at
construction) rather than resolving one from a request it does not have. It is
what stops a page open during decomposition from rendering the
pre-decomposition snapshot beside a fresh approval prompt. The comment endpoints
live on
PlanCommentController (api/controllers/plan_comments.py); a human comment's
author is taken from the authenticated user, never the request body, and an agent
reply is attributed to the responding role.
Dispatch on Approval¶
Approve/reject route through the existing idempotent /approvals/{id} path into
try_plan_review_resume (api/controllers/_plan_review_resume.py), keyed off the
ApprovalSource.PLAN_REVIEW discriminator:
- The decision is reflected onto the durable plan first (
APPROVED/REJECTED). - On approve, three writes settle the plan's own record before anything is built
from it, in this order:
replay_decided_questionswrites back every answer already decided against the plan, so an answer whose write-back failed after its decision was durable costs a retry rather than the operator's answer.retire_open_questionscloses whatever nobody answered. Past this point the plan's context is stamped onto every child task's brief, so a late answer would reach no task, no agent and no prompt while the operator was told it was sent. It runs after the replay so a decision already taken lands before its row shuts.record_resolved_decisionswrites each decision item's resolved option (the reviewer's pick, else the owner's recommendation) tochosen_option_id. Dispatch strips decision ids from the work items' dependencies because the decision is made by approval time, whileitem_is_doneasks whetherchosen_option_idis set: unwritten, the two disagree and an initiative can dispatch every item and never complete.
- Then the project is linked and the plan moves to
EXECUTING, the durable plan is rebuilt viadecomposition_from_planand dispatched throughcoordinate(precomputed_plan=...). A dispatch failure (missing coordinator, missing task, missing plan, or a coordinator error) marks the parent taskFAILEDso the stuck plan surfaces on the board, and moves the plan toFAILEDcarrying the redacted cause. The decision stands, but the plan does not: leaving itAPPROVEDwould show a plan the operator greenlit with nothing running under it and nothing saying why. - On reject, the parent task is cancelled and nothing builds.
- The gate persists the plan before parking the approval; if the approval write
fails, the filled plan is marked
FAILED(carrying the reason) rather than deleted, so the failure stays visible in Plan Review instead of vanishing.
Configuration¶
The feature reads six coordination.* settings
(settings/definitions/coordination.py) plus one shared budget.* bound, split
across two subsystems that own different halves of it. The master gate belongs to
plan_review_gate, whose activation reads coordination.plan_approval_required.
The other five are panel configuration: plan_review_panel bakes them into a frozen
config when it is built, so its SubsystemSpec declares exactly those five with
rebuild_on_change, and a write tears the panel down and rebuilds it rather than
waiting for a restart.
| Setting | Default | Purpose |
|---|---|---|
coordination.plan_approval_required |
true |
Master gate: when off, splittable team work dispatches straight to the coordinator and no plan is parked. On by default so every greenlit initiative parks a plan for holistic review. Everything below is inert until this is on. |
coordination.plan_review_panel_enabled |
true |
Whether the stakeholder panel runs before the plan reaches the human. Defaults on, but only takes effect once approval is gated and a provider is wired; otherwise the plan is parked with review = None. |
coordination.plan_review_panel_size |
4 (max 8) |
Maximum panellists seated (the relevant leads sized to the plan, not everyone). |
coordination.plan_review_panel_max_turns |
6 |
Hard turn cap per panellist session before it must submit a verdict. |
coordination.plan_review_panel_cost_ceiling |
1.0 |
Per-reviewer spend ceiling (base currency); the session halts once accumulated cost reaches it. |
coordination.plan_review_max_revision_rounds |
2 (max 5) |
How many times a reviewed plan may be sent back to be re-planned before it is parked for the operator regardless. Each round costs a fresh decomposition and a fresh panel, so the cap is what stops a panel and a planner that disagree from arguing indefinitely. 0 makes the panel advisory: findings are still recorded and shown, but nothing acts on them. |
budget.session_token_ceiling |
2000000 |
Per-reviewer token ceiling, shared with every other bounded helper session. The money ceiling above measures nothing against a connection that bills by flat subscription, where cost never rises and the panellist's only other bound is its turn cap. |
Workspace¶
The Plan Review workspace (web/src/pages/PlansPage.tsx, PlanDetailPage.tsx, and
web/src/pages/plans/) is a pure API consumer: it hydrates from GET /plans, walks
every cursor page so the review inbox can filter and sort across the whole set, and
writes every change through the API. The detail page reworks items (title,
description, owner, complexity, stakes) or sends the plan back for changes, and
surfaces a disconnected-updates banner when the WebSocket drops. Beyond the item
list, it renders review panels derived from the plan (no extra persisted state):
- Decomposition failure (
PlanFailureBanner): shown only for aFAILEDplan, surfacing itsfailure_reasonso the operator can see why the run failed and start a fresh one. - Delivery verdict (
PlanEvaluationPanel): the evaluate stage's judgements hydrated fromGET /plans/{id}/evaluation, newest first, each objective criterion with the judge's evidence, so a parked initiative explains which criteria failed. Hidden when nothing has judged the plan. - Needs your input (
PlanOpenQuestionsPanel): the planner's open questions and assumptions to answer or correct before approving. Each question carries its own answer box, because this panel is the only surface that can decide one: the generic Approvals inbox filters everyplan_reviewrow out by design (useApprovalsData), so a question sent anywhere else is a question nobody can settle. Sending an answer approves that question's own parked approval with the answer as its comment, which is what writes it onto the plan. A question with no parked approval left says so rather than offering a box: once the plan starts building, its questions are retired unanswered and an answer would reach no task, no agent, and no prompt. - Cost forecast (
PlanForecastPanel): the plan'sforecast_idhydrated to show the estimate with its band, decision state, and any hard-ceiling halt. - Staffing (
PlanStaffingPanel): per-owner item load derived from item owners, flagging bottlenecks and unassigned work. - Success-criteria coverage (
PlanCoveragePanel): each objective criterion and the items that advance it, flagging any criterion nothing covers. - Stakeholder review (
PlanReviewPanel): the panel's consolidated verdict and each lead's findings. - Changes since last revision (
PlanVersionDiff): items added / removed / modified versus the last version snapshot. - Timeline (
PlanTimeline): execution waves derived from the dependency DAG. - Decision options and discussion (
PlanItemCard): each decision item's options (pick recorded viaPATCH /plans/{id}) and a per-item comment thread that updates live over theplanschannel.