Skip to main content

0004. Model the plan as a depth-ordered tree and add a per-turn editable Objective

Context and problem statement

The Planning panel (formerly “To Dos”) needs two things it could not express before: an Objective (the agent’s evolving, one-line understanding of the user’s goal) and a hierarchical plan (1, 1.1, 1.1.1, 2 …) instead of a flat bullet list. Both must round-trip through the existing snapshot/branch machinery so that “Continue from here” on an earlier node shows the objective and plan exactly as they were then (the snapshot(k+1)-or-live invariant from ADR-0003). The tension is in the data model: branch rows get fresh primary keys, and snapshots serialise the workspace to plain JSON, so any representation of the plan’s tree shape and of the objective has to survive both copies cleanly.

Decision drivers

  • Branch/restore fidelity. Going back to a node must reproduce its objective and plan; everything must ride the existing resolve_restore_state / restore_workspace path with no new copy mechanism.
  • Snapshot portability. workspace_snapshots.todos_json is a flat JSON array; the tree must serialise into it and re-materialise without depending on row ids (which change on a branch).
  • Backward compatibility. Existing rows and pre-feature snapshots must remain valid (additive columns, sensible defaults).
  • Tool ergonomics. The agent must address and nest steps unambiguously, and toggling a step must stay stable across a turn.
  • Minimal blast radius on the SSE turn loop, the dynamic prompt, and the graph builder.

Considered options

  1. Plan model: depth column + global pre-order position. Keep the flat, ordered todos rows; add one depth integer. The ordered list is the depth-first serialisation of the tree.
  2. Plan model: parent_id self-FK. Make todos an adjacency list with a nullable self-referential parent.
  3. Objective storage: objectives table (one row/thread) + snapshot column. Mirror the documents pattern.
  4. Objective storage: LangGraph checkpointer state. Carry the objective in the agent’s message-channel state.

Decision outcome

Chosen: Option 1 (depth column) for the plan, and Option 3 (dedicated objectives table + workspace_snapshots.objective_text) for the objective. The flat, depth-annotated, pre-ordered list is literally the DFS serialisation of the tree, so it drops into todos_json with one extra scalar key and needs no parent-id remapping when a branch re-materialises the rows. The objective mirrors the document: a per-thread row the agent writes via set_objective and the user edits via PUT /objective, snapshotted per turn so it restores for free on a branch. Dotted numbering (1, 1.1, …) is derived at render time from depth (compute_dotted_numbers), so arbitrary nesting depth costs nothing in storage. The agent nests with add_todos(parent_index=…) and still addresses every step by the stable 1-based flat index that toggle_todos already used; dotted numbers are display-only (the mutating todo tools are batch-only, each taking a list of steps).

Positive consequences

  • Snapshot round-trip is trivial: {text, done, position, depth} in, the same out; pre-feature snapshots default depth to 0 (flat) and objective_text to None, exactly like the pre-lock locked default.
  • No new branch-copy code: objective + depth flow through the existing resolve_restore_state / restore_workspace / delete_all_for_thread.
  • Arbitrary nesting depth with no schema change; the depth cap (if ever wanted) is a render/validation constant, not a migration.

Negative consequences

  • add_todos must compute a pre-order insertion point and shift later positions when nesting under a non-terminal parent (one extra UPDATE).
  • Drag-reorder stays flat / position-only (depth preserved); moving a subtree to a new parent from the UI is deferred. An orphaned deeper item (e.g. a sub-step dragged above its parent) is clamped at render so numbering stays legible.
  • Progress is counted leaf-only (a step with no nested children) so a checked parent doesn’t inflate the bar; the prompt’s notion of “complete” matches this.

Pros and cons of the options

Option 1: depth column (chosen)

  • + Flat ordered+depth list = DFS serialisation; ids irrelevant on restore.
  • + One additive column; pre-feature rows default to a flat list.
  • Non-terminal nesting needs a position shift; cross-level drag deferred.

Option 2: parent_id self-FK

  • + Canonical tree; subtree moves are well-defined.
  • Snapshot JSON has no stable ids, so a positional parent reference must be serialised anyway, reinventing depth/index inside the JSON.
  • Restore must two-pass insert and remap parent_id to new row ids; forces an ON DELETE cascade-vs-orphan decision.

Option 3: objectives table + snapshot column (chosen)

  • + Mirrors documents; user-editable; restores on a branch for free.
  • + Lives outside the message channel, so it never bloats checkpointer state.

Option 4: objective in checkpointer state

  • Per-node restore of a checkpointer field is awkward; couples the objective to message history and duplicates it every turn.