Files
kandev/docs/specs/plugins/system-design/task-dependency-projection.md
T
2026-09-18 14:55:51 +00:00

28 KiB

id, title, status, system, requirements, owners, created, last_updated
id title status system requirements owners created last_updated
plugins-task-dependency-projection-design Task dependency projection on the plugin data API draft plugins
REQ-PLUGINS-TASK-DEPS-001
REQ-PLUGINS-TASK-DEPS-002
kandev
2026-09-17 2026-09-17

Task dependency projection on the plugin data API System Design

Purpose and boundaries

This design owns how the plugin task read model carries task dependency state: the wire contract on the gRPC plugin surface and the canvas JSON protocol, the batched derivation step, the ordering, where the projection is attached, and the refresh rule.

It does not own what an edge end may say. Scope redaction (REQ-PLUGINS-TASK-DEPS-003) and ends the response does not contain (REQ-PLUGINS-TASK-DEPS-006) are owned by dependency edge ends, which this design's field set and derivation feed.

It does not own what a response may cost or how large it may be. The query bound, the edge-end fan-out, the per-task list cut, and the response-size bound (REQ-PLUGINS-TASK-DEPS-004) are owned by response bounds. This design fixes the set of objects a response serializes; that one counts derivations and bytes over it.

It does not own the refresh contract. When a consumer re-reads, what the events carry, and the no-lock and no-snapshot rules (REQ-PLUGINS-TASK-DEPS-005) are owned by refresh contract, which this design's field set and derivation feed.

It does not own dependency semantics. The derivation of blocked, blocked_reason, and the two edge lists, the resolution vocabulary, the fail-closed rule, and the deferred-launch intent are owned by task dependencies. This design consumes that derivation and adds no second definition of it.

Adjacent contracts used but not owned:

Requirement mapping

Requirement Design section
REQ-PLUGINS-TASK-DEPS-001 Data and contracts, Control flow
REQ-PLUGINS-TASK-DEPS-002 Failure and recovery

Components and responsibilities

The projection crosses four existing layers. Each keeps its current responsibility; none gains a dependency query of its own.

Component Responsibility
Task service dependency derivation Sole producer of the derived view for a batch of tasks. Already batched for the Kanban board.
Plugin host task reader Requests one batched derivation per response, after pagination, and stamps it onto the SDK task DTOs. Mirrors how linked pull requests are attached.
Plugin SDK task type and proto mapping Carries the projection as additive, hand-mapped fields with a stable wire form in both directions.
Canvas JSON adapter Projects the SDK task into the browser JSON shape and applies scope redaction, because it is the only layer that holds the canvas binding.

There is no single funnel today, and the design does not pretend otherwise. It states the attachment point once, as a property of the response rather than of a call site:

A response carries the projection on every plugin task read model object it serializes, and on no other. A task read whose object never reaches a wire encoder does not derive it.

The rule is stated on the response because the call sites are not in one-to-one correspondence with responses: one route can read three task objects and return one, and one host method serves both kinds of caller.

The test a builder applies is mechanical, because "serializes" has exactly three implementations. A task object derives the projection if and only if it reaches toProto or tasksToProto on the gRPC side (pkg/pluginsdk/host.go), or webAppTaskFromSDK on the canvas side (internal/plugins/webapp_protocol_json.go:120). Everything else read on the way is an intermediate.

Applied today, the rule resolves to nine routes: the six gRPC handlers on grpcHostServer -- ListTasks, GetTask, CreateTask, UpdateTask, MoveTask and PreviewPluginOwnedTaskTree -- and three canvas routes, the task list, the single-task GET, and the PATCH. The canvas list route reaches the encoder from two sources: its task-scope branch serializes a single task obtained from Tasks().Get and never calls Tasks().List at all (internal/plugins/webapp_protocol_data.go:43), which a checklist written from host methods misses entirely.

That list is derived from the rule and does not replace it. If a later call site makes the list stale, the rule governs and the list is what is wrong. This enumeration has already been incomplete twice, so the contract may not depend on it being complete a third time.

Data and contracts

Task read model additions

Five fields plus two bounding flags, on the gRPC Task message, the SDK Task type, and the canvas task JSON, with one name per field across all three:

Field Type Meaning
blocked bool Derived blocked verdict for this task.
blocked_reason string pending, failed, unknown, or empty. Empty if and only if blocked is false.
depends_on repeated ref Direct predecessors.
blocks repeated ref Direct dependents.
depends_on_truncated bool The predecessor list was cut to the per-task limit.
blocks_truncated bool The dependent list was cut to the per-task limit.
start_when_unblocked bool The task's stored chain-launch intent, reported raw.

An edge-end ref carries id, title, state, and status:

  • status on a depends_on entry is exactly resolved, failed, or pending.
  • status on a blocks entry is always empty. Resolution is a property of a predecessor, so a dependent has none. This diverges from the Kanban board wire shape, which copies a status onto blocks entries and can emit the derivation's internal missing marker there; emptying it closes the public vocabulary and removes that leak.

The ref type is a new named message on the proto surface so both lists share one shape, and a mirrored SDK type with explicit toProto and fromProto functions, per ADR 0043's hand-mapped-DTO rule.

Presence and the absent-versus-empty rule

All seven fields are always populated on every returned task, including a task with no edges. This is the one intentional departure from the Kanban and WebSocket task payload, where an absent projection means "not computed" and an empty list means "no edges".

The departure is safe only because the plugin surface always computes the projection, so absence never occurs within one host version. It follows the in-repo precedent for derived task-DTO booleans always serialized rather than omitted when false -- is_ephemeral, autopilot, wip_admitted -- for the same reason: a missing key is indistinguishable from false.

On the proto surface, blocked, the two flags, and start_when_unblocked are plain bool and blocked_reason is a plain string; the always-computed rule is what makes their zero values meaningful rather than ambiguous. In the canvas JSON, none of the seven keys is omitted when empty, for the same reason.

The two lists need one more thing said about them, because "not omitted" is not enough on that surface: an empty depends_on or blocks is encoded as [], never null (AC-PLUGINS-TASK-DEPS-001.18). A null would be exactly the third state the presence rule exists to forbid, and it is the default encoding of an unallocated list, so the adapter must allocate an empty one rather than pass through whatever the derivation left. The neighbouring task fields on this surface are declared omitempty, which makes the omission trap live too; both are avoided by the same explicit construction. This binds the encoder, not the derivation, and the withheld verdict is encoded the same way.

What start_when_unblocked reads. models.HasStartWhenUnblockedIntent, which returns the start_when_unblocked key of the task's deferred_launch metadata record. That key separates a dependency-chain intent from a WIP-overflow one, which share the record, so the projection calls the accessor rather than testing the record's presence. RemoveDependency does not clear the record, which is why AC-PLUGINS-TASK-DEPS-001.10 reports the stored value rather than re-judging it against the current edges.

Wire compatibility

Field additions only, per ADR 0043's additive-only rule. New proto field numbers continue past the highest number currently assigned on the Task message; no existing number, name, or type changes, and no field is removed or renumbered. A plugin built against the previous contract ignores the new fields.

A plugin built against the new contract reading an older host is the case that needs care, and it is not benign. proto3 gives these scalars no field presence, so the older host's response decodes as blocked: false, empty lists, both flags false -- a well-formed "not blocked, no edges" answer, and the exact opposite of the withheld verdict. The client cannot tell it from a real one by looking at the fields, so the presence guarantee holds within one host version only (AC-PLUGINS-TASK-DEPS-001.15).

What separates them is the install-time floor, not a runtime check (AC-PLUGINS-TASK-DEPS-001.16). There is no runtime signal to consult and none is added: the gRPC handshake's protocol version is a fixed constant, the canvas context's protocol version is likewise fixed and does not change here, and the capability set is unchanged because the projection is a field set on the already-approved api_read:tasks resource. Nothing on either surface reports the host's feature set. The mechanism that does exist is the manifest's min_kandev_version, compared against the running host during install and rejecting an older one outright with no partial registration (CheckMinimumKandevVersion, internal/plugins/manifest/semver.go:12-28, called from service_install.go:259). That check is capability-agnostic: it reads one declared floor and compares it, and knows nothing about which capabilities the manifest requests. A consumer that must not misread the ambiguous bytes declares the first host release carrying this capability there.

The api_read:messages floor is not that mechanism and must not be copied as though it were. It is capability-keyed, runs at Manifest.Validate() time against a hardcoded constant, and never consults the running host (RequiresMessagesCapabilityMinimum / validateCapabilityMinimumVersions, internal/plugins/manifest/min_version_policy.go:11-28). Building that shape for api_read:tasks is precisely what the next paragraph forbids.

That floor is declared by the author, not enforced per capability (AC-PLUGINS-TASK-DEPS-001.17): a capability-keyed minimum on api_read:tasks would reject every already-installed plugin that reads tasks and wants nothing to do with dependencies. The residue is honest and documented -- a plugin that omits the floor, or is sideloaded past the install path, receives the ambiguous bytes with no signal. Adding a wrapper message to manufacture field presence was rejected separately: it would change the field shape for every consumer to serve a check the install gate already answers.

The canvas protocol version does not change: the browser data protocol already documents its task object as a field list, and this is an addition to that list. A canvas ships inside a plugin package, so the same manifest floor covers it.

Edge order

depends_on is ordered by edge creation time ascending, tiebroken by predecessor task id ascending. blocks is ordered by edge creation time ascending, tiebroken by dependent task id ascending. The tiebreak column is the far end's task id, which is unique within one list because an edge is keyed by the pair, so both orders are total.

In storage those are task_blockers.created_at for both lists, tiebroken by task_blockers.blocker_task_id for depends_on and by task_blockers.task_id for blocks. The batched predecessor query selects neither column today, so establishing the order means adding the ORDER BY, not sorting after the read.

The blocks direction already has exactly this order in its batched query. The depends_on direction does not: the batched predecessor query has no ORDER BY at all, so today's order is whatever the storage engine returns, even though the Tasks system design states predecessors are listed in creation order. Satisfying AC-PLUGINS-TASK-DEPS-001.8 therefore requires that ordering to be established in the shared derivation.

That is a change to a shared path and also affects the Kanban board's order. It is a correction toward an already-frozen Tasks contract rather than a new contract, so it belongs in the shared derivation and not in a plugin-only re-sort: a plugin-only sort would leave the two surfaces disagreeing about an order the Tasks design has already fixed. Implementation must treat the shared derivation as the ordering authority and flag the board-visible effect.

This is one of five changes this design requires in the shared derivation. They are enumerated here because Build plans its work orders off this list, and an undercount silently under-scopes the Tasks-owned half of the capability:

  1. Edge order, above: an ORDER BY on the batched predecessor query.
  2. Dangling-edge drop in the dependent direction, in the sibling's Edge ends outside the response.
  3. Fail closed on every derivation-failure path, in Failure and recovery. Three of the four do not today.
  4. Carry the far end's workspace id on the edge-end ref, in the sibling's Redaction rule, so scope admission stays query-free.
  5. Chunk the derivation's batched id queries -- edge-end resolution, and the task-keyed predecessor and dependent queries on the unpaginated preview flow -- in the sibling's Edge-end fan-out, so neither family can exceed the database's bind-parameter ceiling.

All five are corrections toward, or additive reads on, the existing Tasks contract; all five are board-visible; none needs a migration; and all five must be planned as work on the Tasks-owned derivation rather than as plugin-local code.

Control flow

For a list read:

  1. The host reader resolves scope, fetches, filters, and sorts tasks as today.
  2. The page is cut to the requested limit.
  3. On the canvas list route, the scope filter runs here, and the steps below see only its survivors.
  4. The remaining tasks are mapped to SDK DTOs, and one batched dependency derivation runs for exactly their task ids.
  5. The derived view is stamped onto each DTO, with per-list bounding applied.
  6. For a canvas request, the JSON adapter projects each DTO and redacts out-of-scope edge ends.

For a single-task read, and for the task object returned by a write, steps 4 to 6 run for that one task. For the plugin-owned task-tree preview they run once over the whole returned tree: one derivation, not one per task, and the tree is not paginated, so response bounds states what bounds that response and why the smaller-limit remedy is the one remedy that does not apply to it. The derivation for a write response runs after the write commits, so the response reflects the post-write state.

It is not, however, a snapshot taken atomically with the write. The derivation takes no dependency mutation lock (see refresh contract), so the observation point is the derivation instant, and an edge mutated between commit and derivation is permitted to appear in the response. This is the AC-PLUGINS-TASK-DEPS-001.13 contract: the write response matches what a read would report at that instant, not what the write itself established. A consumer needing a later state refetches on signal.

Ordering of steps 2 and 4 is load-bearing. Deriving before pagination would fan the derivation out over every task the filter matched, which is the fault the pull-request attachment step already avoids by attaching after pagination.

The canvas list route applies its own scope filter after the host has paginated, and the derivation runs after that filter. That is what makes the attachment rule's second sentence true here: a task webAppTaskMatches drops never reaches webAppTaskFromSDK, so it derives nothing. Implementation collects the surviving tasks first and derives over that slice, rather than deriving over the host's page inside the existing filter-and-encode loop (internal/plugins/webapp_protocol_data.go:56-68). Filtering first costs AC-PLUGINS-TASK-DEPS-004.1 nothing: the predicate is query-free in instance, workspace, task and repository scope, and in session scope it already issues a per-task session read this capability neither adds nor removes, which is not a dependency read. Deriving over the host's page would also satisfy AC-PLUGINS-TASK-DEPS-004.2, but it contradicts the rule and spends the derivation on tasks the canvas is about to drop.

Reads whose task object is discarded

Tasks().Get is the method that makes the response-level rule necessary rather than merely tidy. It has six callers, and only two of them serialize the task they read:

Caller Uses the task for Serializes it
listWebAppTasks, task-scope branch (:43) the response itself Yes
getWebAppTask (:76) the response itself Yes
updateWebAppTask (:108) binding preflight, then writes and returns the written object No
sendWebAppMessage (:166) binding preflight; returns no task No
listWebAppWorkflows (:209) reads WorkflowID to filter workflows No
listWebAppWorkflowSteps (:249) reads WorkflowID to authorize No

All six are in internal/plugins/webapp_protocol_data.go. An earlier draft named only two of the four that discard -- which is why the contract is the rule above and not a list, since a seventh caller would make any replacement list wrong again. Under the rule the four need no enumeration, because none reaches an encoder.

The consequence for placement. The projection therefore may not be attached unconditionally inside Tasks().Get: four of its six callers would derive for a response that carries no projection, and the PATCH route would derive twice. Two shapes satisfy the rule and either is acceptable -- attach in the responders that serialize, or keep a non-attaching lookup for the preflights and attach in Tasks().Get for the rest. What the design forbids is the third shape: the helper inside Tasks().Get, with the preflight callers expected not to mind.

The PATCH route derives once. Its body declares title, description, state and workflow_step_id as independently optional with no mutual exclusivity, so one valid body naming both a title and a step takes both write branches -- Tasks().Update (:124) then Tasks().Move (:135) -- and returns one object while discarding the other (:147). One response, one derivation, on the object serialized.

Failure and recovery

One verdict covers every path on which the projection is unavailable to the caller. The withheld verdict is blocked: true, blocked_reason: unknown, both lists empty, both truncation flags false, and start_when_unblocked false (AC-PLUGINS-TASK-DEPS-001.14). It reuses the derivation's existing unknown vocabulary rather than adding a value, so no consumer needs a new branch.

The upstream derivation already fails closed this way on a predecessor read error. The plugin surface preserves that verdict verbatim, and extends it to the paths the derivation never sees:

  • A failed derivation does not fail the task read. The task is returned with the withheld verdict and every other field intact.
  • A failed derivation is not silently degraded to empty. This is the deliberate difference from the pull-request attachment step, which returns an empty list on a failed lookup: an absent pull request is a plausible true answer, while an empty edge list is indistinguishable from a real answer.

Four paths produce it, enumerated so none is discovered later as a degraded empty list: a failed read of the predecessor direction, a failed read of the dependent direction, a failed resolution of edge-end detail, and a derivation source absent or not wired to the plugin host at all. There is no configuration in which tasks silently read as unblocked.

Only the first of the four fails closed today, which is why this is change 3 of the five listed under Edge order. The dependent-direction read error is swallowed into an empty map, and the derivation then returns a real, non-unknown verdict computed from the predecessor tally alone; an edge-end batch error marks every unresolved end pending, which reads as a live predecessor rather than an unknown one; and an absent derivation source returns an empty map, which reads as blocked: false on every task. Each is exactly the "degraded to empty" this section forbids.

The withheld verdict is whole-task and indivisible: a failure on any of the four paths yields every field of AC-PLUGINS-TASK-DEPS-001.14 together. A dependent-direction failure does not empty blocks while keeping a real blocked -- that publishes a verdict derived from half a read, the confidently-wrong answer the requirement exists to prevent.

A fifth path is authority rather than failure, and applies only where a response is returned at all. A surface gated on api_read:tasks -- the read RPCs and the canvas data routes -- keeps rejecting a caller without it outright; the withheld verdict never becomes the body of a call that should have been refused (AC-PLUGINS-TASK-DEPS-001.11). It applies where the read model is returned behind a different gate: the gRPC task-write RPCs gate on the write capability, granted independently of the read one, so a caller holding only api_write:tasks would otherwise read the graph through the write response. The projection is therefore stamped behind the read-capability check on every surface returning the read model, not only the read RPCs. The shape is uniform on purpose: a distinct "forbidden" projection would tell a caller there was something to withhold.

Failure granularity follows the derivation's batch boundaries, not the task. A failure that affects the batched read yields the withheld verdict for every task in that batch; a failure confined to one task's edge-end resolution yields it for that task alone. Per-task isolation of a batch-wide failure is deliberately not promised, because detecting it would need the per-task queries that AC-PLUGINS-TASK-DEPS-004.1 forbids.

Persistence

None. No table, column, migration, or cache is added. The projection is derived on every read, which is the invariant the Tasks design exists to protect: a stored blocked flag would be read by the auto-start gate, and a stale value there would launch work whose predecessor never ran.

Observability

One structured log record per failed dependency derivation, carrying plugin, instance, resource type, operation, and result code. No task title, task id, or edge content is logged, matching the isolated-web-app diagnostics rule that keeps data content out of logs and metrics.

The unit is the derivation attempt, not the withheld task: one batched derivation that fails emits one record however many tasks it withholds for, and a failure confined to one task's edge-end resolution likewise emits one. Records therefore do not scale with page size, which is what stops a failing dependency store turning a 200-task read into 200 log lines; since no field identifies a task, per-task records would be indistinguishable anyway.

That log record is the whole of the observability contract (AC-PLUGINS-TASK-DEPS-002.4). No counter and no metric namespace is added, and the design does not claim a withheld verdict is visible on an existing data-request counter -- it is not, because a failed derivation does not fail the surrounding request, so the request is counted as a success. An operator who needs the rate of withheld verdicts reads the log records. Promoting that to a counter is a deliberate non-goal: it would be the capability's only metric, and the isolated-web-app diagnostics surface is where one would belong.

Test strategy

  • Proto and SDK round trip for the ref type and all seven fields, including the empty-list and empty-status cases.
  • Canvas JSON projection: every field present on a task with no edges, and no key omitted when empty.
  • Parity: the same task yields the same projection values on the gRPC and canvas surfaces, redaction aside.
  • Order: predecessors and dependents come back in the defined order with the named tiebreak, repeatably.
  • Withheld verdict: each of the four failure paths -- predecessor read, dependent read, edge-end resolution, and an unwired derivation source -- yields blocked: true, reason unknown, empty lists, both flags false, without failing the read and without omitting a field.
  • Failure granularity: a batch-wide failure withholds for every task in the batch; a failure confined to one task's edge-end resolution leaves the other tasks' real verdicts intact.
  • Capability: a caller holding only the write capability receives the withheld verdict from a task-write RPC's task object, byte-identical to the derivation failure case, and never an edge end; a caller without api_read:tasks on a surface gated on it is rejected outright, with no task object returned.
  • start_when_unblocked reports the stored intent: true for a task whose last edge was removed, false for a WIP-overflow-only deferred launch.
  • Write response carries the post-write projection, asserted on each of the three write RPCs and on the preview RPC, not only on the read path.
  • Empty lists encode as [] in the canvas JSON: asserted on the raw response body for a task with no edges and for the withheld verdict, so a null or an omitted key fails rather than being normalized away by a typed decoder.
  • Derivation count per response, asserted with a counting fake rather than on the response body: the canvas PATCH route derives exactly once for a body naming both a title and a workflow step, which takes both the update and the move branch; and each of the four Tasks().Get callers that discards its task -- the PATCH preflight, the message route, the workflow list, and the workflow-step list -- derives not at all. The last two fail if the projection is attached unconditionally inside Tasks().Get.
  • Every serializing route carries the projection, asserted per route rather than once, over the nine routes enumerated in Components and responsibilities -- the canvas list route's task-scope branch included.
  • The canvas list route derives only for the tasks it serializes: on a page where the scope filter drops tasks, the derived task-id set is exactly the survivors and no dropped task is derived for. Asserted on the id set handed to the derivation, because the response body cannot distinguish the two orders.

There is no new Kandev UI, so the web E2E suites are not a coverage source. The consumer-facing proof is a canvas that renders a workspace dependency graph from the data API alone.

Documentation

Seven things across this capability are consumer contract rather than wire shape, so they must be stated in the public documentation rather than inferred. One is this design's: the min_kandev_version floor and the consequence of omitting it. Two are the refresh contract's, and four are response bounds'. All seven go in docs/public/canvases.md, docs/public/plugins-authoring.md, and the canvas authoring browser-API reference, whose task-object field list and event section both need updating.