277677f330
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
306 lines
18 KiB
Markdown
306 lines
18 KiB
Markdown
---
|
|
id: plugins-task-dependency-projection
|
|
title: Task dependency projection on the plugin data API
|
|
status: draft
|
|
system: plugins
|
|
owners:
|
|
- kandev
|
|
created: 2026-09-17
|
|
last_updated: 2026-09-17
|
|
---
|
|
|
|
# Task dependency projection on the plugin data API Requirements
|
|
|
|
## Overview
|
|
|
|
The plugin task read model exposes `parent_id` and nothing else about how one
|
|
task relates to another. Dependency edges ("task B cannot start until task A
|
|
succeeds") are derived for every Kandev board read, but the Plugin Host data API
|
|
and the isolated-canvas data protocol both drop them, so a gRPC plugin or a
|
|
canvas draws the parent/child tree ("part of") and never the dependency graph
|
|
("not until") from tasks it has already read. This capability adds the edges, the
|
|
derived blocked verdict, and the deferred-launch intent to that read model as a
|
|
read-only projection, on both surfaces.
|
|
|
|
Plugins owns it because the extended contract is the plugin and canvas read
|
|
model ([plugins README](../README.md)). Dependency semantics stay with Tasks
|
|
([task dependencies](../../tasks/system-design/task-dependencies.md)), which this
|
|
capability re-projects, never redefines. Identities use the `TASK-DEPS` token,
|
|
distinct from `REQ-TASKS-TASK-DEPENDENCIES-001`.
|
|
|
|
Two sibling documents own the questions this one does not. What an edge *end*
|
|
may say lives in [dependency edge ends](task-dependency-edge-ends.md), which owns
|
|
`REQ-PLUGINS-TASK-DEPS-003` (which ends a canvas may see described, and what is
|
|
left of one it may not) and `REQ-PLUGINS-TASK-DEPS-006` (ends the response does
|
|
not contain: archived, out of page, or removed); it defines *canvas scope* and
|
|
*redaction*. What one response may cost and how large it may be lives in
|
|
[response bounds](task-dependency-response-bounds.md), which owns
|
|
`REQ-PLUGINS-TASK-DEPS-004` (query count, list cut, edge-end fan-out, and the
|
|
response-size bound on each surface).
|
|
|
|
This document owns the projection itself: the field set, the derivation, the
|
|
order, and the refresh rule. Criteria here defer to the edge-ends sibling
|
|
wherever an end's `title`, `state`, or existence is at stake, and to the
|
|
response-bounds sibling wherever a query count, a list length, or a response size
|
|
is.
|
|
|
|
## Prior art
|
|
|
|
**Leg 1: Kandev's own prior reasoning (wiki). Receipt: did not run.** No
|
|
`wiki-query` in either skill directory or on `PATH`, no `~/.obsidian-wiki/`, no
|
|
`OBSIDIAN_VAULT_PATH`; no vault path or QMD collection can be reported.
|
|
Tool-unavailable, not an empty result.
|
|
|
|
In its place the in-repository record was searched (`scripts/list-docs.py
|
|
decisions`, `docs/specs/{plugins,tasks}/**`). Three positions found there are
|
|
adopted rather than re-derived: **ADR 0043** (DTO fields are additive-only;
|
|
hand-mapped DTOs read through the service layer);
|
|
**[isolated web-app contributions](../system-design/isolated-web-app-contributions.md)**
|
|
(canvas routes reuse the Host data API DTOs, so a field added for one adapter is
|
|
returned by both with the same nullability and authorization, as
|
|
`workflow_step_id` established); and
|
|
**[task dependencies](../../tasks/system-design/task-dependencies.md)** (blocked
|
|
state is derived on every read, never persisted, because the auto-start gate
|
|
would read a stale copy).
|
|
|
|
**Leg 2: what other products shipped (saas-kb). Receipt: did not run.** No
|
|
`search_fsm_docs` tool and no `saas-kb` MCP server in this session's tool
|
|
surface, and no tool-discovery tool to find one. No query issued.
|
|
|
|
**What we are doing differently.** Two departures, both normative below. The
|
|
board treats an **absent** projection as "unknown" and an empty list as "no
|
|
edges"; this contract computes it on every read, so within one host absence never
|
|
occurs (`AC-PLUGINS-TASK-DEPS-001.2`) and an underivable projection reports the
|
|
withheld verdict (`REQ-PLUGINS-TASK-DEPS-002`). And `Task.pull_requests` degrades
|
|
to an empty list on a failed lookup; dependencies must not, because an empty edge
|
|
list is indistinguishable from a real answer.
|
|
|
|
## Terminology
|
|
|
|
- **Dependency edge:** A directed peer-to-peer relation "this task depends on
|
|
that task", distinct from the parent/child subtask hierarchy. Its two ends are
|
|
the **predecessor** and the **dependent**, listed in `depends_on` and in
|
|
`blocks` respectively; **edge end** means either.
|
|
- **Projection:** The derived, never-persisted dependency field set on one task,
|
|
enumerated in `AC-PLUGINS-TASK-DEPS-001.1`.
|
|
- **Plugin task read:** Any surface returning the plugin task read model: the
|
|
gRPC `ListTasks`, `GetTask`, task-write and task-tree-preview RPCs, and the
|
|
canvas `GET`/`PATCH ./_kandev/v1/data/tasks[/{task_id}]` routes.
|
|
|
|
## Requirements
|
|
|
|
### REQ-PLUGINS-TASK-DEPS-001: Dependency projection on the plugin task read model
|
|
|
|
**Intent:** A canvas author reading tasks draws the dependency graph from the
|
|
task read model alone, each task naming its direct predecessors and dependents
|
|
with the same edges and verdict the board shows, without a second data source.
|
|
|
|
#### Acceptance criteria
|
|
|
|
- **AC-PLUGINS-TASK-DEPS-001.1:** Every plugin task read shall return
|
|
`blocked`, `blocked_reason`, `depends_on`, `blocks`, `depends_on_truncated`,
|
|
`blocks_truncated`, and `start_when_unblocked` for each task in the response.
|
|
- **AC-PLUGINS-TASK-DEPS-001.2:** Every one of those fields shall be present on
|
|
every task returned by a host implementing this capability, including a task
|
|
with no edges, so a consumer never treats absence as a third state. Scoped by
|
|
`AC-PLUGINS-TASK-DEPS-001.15`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.3:** When `blocked_reason` is not `unknown`, an
|
|
empty `depends_on` or `blocks` shall mean "no edge in that direction". When it
|
|
is `unknown`, both lists shall be non-authoritative and shall not be read as
|
|
"no edges"; `blocked_reason` shall be the only discriminator.
|
|
- **AC-PLUGINS-TASK-DEPS-001.4:** `blocked_reason` shall be exactly one of
|
|
`pending`, `failed`, `unknown`, or the empty value, and shall be the empty
|
|
value if and only if `blocked` is false.
|
|
- **AC-PLUGINS-TASK-DEPS-001.5:** `depends_on` shall list only direct
|
|
predecessors and `blocks` only direct dependents. Neither shall be transitive
|
|
and neither shall contain the task itself.
|
|
- **AC-PLUGINS-TASK-DEPS-001.6:** Each `depends_on` entry shall carry the
|
|
predecessor's `id`, `title`, `state`, and a `status` that is exactly one of
|
|
`resolved`, `failed`, or `pending`, subject to `REQ-PLUGINS-TASK-DEPS-003`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.7:** Each `blocks` entry shall carry the
|
|
dependent's `id`, `title`, and `state`, and no resolution status, subject to
|
|
`REQ-PLUGINS-TASK-DEPS-003`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.8:** `depends_on` shall be ordered by edge creation
|
|
time ascending, tiebroken by predecessor task id ascending; `blocks` likewise,
|
|
tiebroken by dependent task id ascending. Both orders shall be total and
|
|
repeatable for unchanged data.
|
|
- **AC-PLUGINS-TASK-DEPS-001.9:** For the same task and edges, the gRPC read and
|
|
the canvas read shall report the same values for every projection field,
|
|
allowing only the scope redaction in `REQ-PLUGINS-TASK-DEPS-003`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.10:** `start_when_unblocked` shall report the stored
|
|
deferred-launch intent as recorded, not a judgement about whether it can still
|
|
fire: a task whose last edge was removed keeps reporting true, because removing
|
|
an edge does not clear the intent. It shall be read-only on every plugin
|
|
surface, and false under the withheld verdict of `AC-PLUGINS-TASK-DEPS-001.14`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.11:** A plugin task read shall require no capability
|
|
beyond `api_read:tasks`. A surface whose own gate requires it shall keep
|
|
rejecting a caller without it outright, never turning that rejection into a
|
|
successful response. On a surface gated by something else, including the gRPC
|
|
task-write RPCs whose write capability gates independently, a caller without
|
|
`api_read:tasks` shall receive the withheld verdict of
|
|
`AC-PLUGINS-TASK-DEPS-001.14` and never an edge list, an edge end, or a
|
|
`blocked` value derived from real data.
|
|
- **AC-PLUGINS-TASK-DEPS-001.12:** Reading a task shall not change any task,
|
|
edge, or launch intent, and repeating a read shall only report the current
|
|
derivation.
|
|
- **AC-PLUGINS-TASK-DEPS-001.13:** A task object returned by a plugin or canvas
|
|
write shall carry the projection derived after that write commits, matching what
|
|
a read would report at the instant of derivation, subject to
|
|
`AC-PLUGINS-TASK-DEPS-001.11`. Because that derivation takes no lock
|
|
(`AC-PLUGINS-TASK-DEPS-005.6`), an edge mutated between commit and derivation may
|
|
appear; a consumer needing a later state refetches under
|
|
`REQ-PLUGINS-TASK-DEPS-005`.
|
|
- **AC-PLUGINS-TASK-DEPS-001.14:** The withheld verdict shall be `blocked: true`,
|
|
`blocked_reason: unknown`, empty `depends_on`, empty `blocks`, both truncation
|
|
flags false, and `start_when_unblocked` false. It shall be reported on any
|
|
response that returns a task object but cannot carry a real projection, so no
|
|
response asserts "not blocked" without authority for that claim. It shall never
|
|
be the body of a response that should have been rejected instead;
|
|
`AC-PLUGINS-TASK-DEPS-001.11` governs which callers reach it.
|
|
- **AC-PLUGINS-TASK-DEPS-001.15:** The presence guarantee in
|
|
`AC-PLUGINS-TASK-DEPS-001.2` shall hold within one host version only. The wire
|
|
form carries no field presence, so a host predating this capability yields the
|
|
bytes of `blocked: false` with empty lists, indistinguishable from a real "not
|
|
blocked, no edges" answer.
|
|
- **AC-PLUGINS-TASK-DEPS-001.16:** The mechanism separating the two shall be the
|
|
manifest's `min_kandev_version` floor, declared by the consumer as the first
|
|
host release carrying this capability and enforced at install time: an older
|
|
host rejects the install outright, with no partial registration. The concrete
|
|
version shall be pinned at release and never guessed ahead of one. It is a
|
|
load-time gate, not a runtime check. No runtime capability or version signal
|
|
shall be added, because the projection adds no capability -- it is a field set
|
|
on the already-approved `api_read:tasks` resource, the gRPC handshake version is
|
|
fixed, and the canvas protocol version does not change.
|
|
- **AC-PLUGINS-TASK-DEPS-001.17:** The floor shall not be a capability-keyed
|
|
minimum on `api_read:tasks`, which would reject every installed plugin that
|
|
reads tasks without this capability. It is therefore declared, not validated,
|
|
and the public documentation shall state the floor, the effect of omitting it,
|
|
and that a sideloaded plugin bypassing the install path gets the ambiguous
|
|
bytes with no signal.
|
|
- **AC-PLUGINS-TASK-DEPS-001.18:** In the canvas JSON an empty `depends_on` or
|
|
`blocks` shall be encoded as an empty array, never `null` and never omitted: a
|
|
null is the third state `AC-PLUGINS-TASK-DEPS-001.2` forbids and is the default
|
|
encoding of an unallocated list, so this binds the encoder, not the derivation.
|
|
The withheld verdict shall be encoded the same way.
|
|
|
|
### REQ-PLUGINS-TASK-DEPS-002: Fail-closed derivation
|
|
|
|
**Intent:** A read that cannot be answered must say so; an empty edge list is
|
|
indistinguishable from a real answer and renders a confidently wrong graph.
|
|
|
|
#### Acceptance criteria
|
|
|
|
- **AC-PLUGINS-TASK-DEPS-002.1:** When the dependency derivation for a task
|
|
cannot be completed, the read shall report the withheld verdict of
|
|
`AC-PLUGINS-TASK-DEPS-001.14` for that task.
|
|
- **AC-PLUGINS-TASK-DEPS-002.2:** A failed dependency derivation shall not fail
|
|
the surrounding task read, shall not remove any other field of the task, and
|
|
shall not omit any projection field.
|
|
- **AC-PLUGINS-TASK-DEPS-002.3:** Failure granularity shall follow the derivation's
|
|
batch boundaries. A batch-wide failure shall produce the withheld verdict for
|
|
every task in that batch; a failure confined to one task's edge-end resolution
|
|
shall produce it for that task alone. Per-task isolation of a batch-wide failure
|
|
shall not be required, because `AC-PLUGINS-TASK-DEPS-004.1` forbids the per-task
|
|
queries needed to detect it.
|
|
- **AC-PLUGINS-TASK-DEPS-002.4:** A failed dependency derivation shall emit one
|
|
structured log record per derivation attempt, not one per withheld task. The
|
|
record shall carry plugin, instance, resource type, operation, and result code,
|
|
and shall not record task titles, task identifiers, or edge contents. This
|
|
capability shall add no counter and no metric namespace; the log record is its
|
|
whole observability contract.
|
|
- **AC-PLUGINS-TASK-DEPS-002.5:** Each of the following shall count as a
|
|
derivation that cannot be completed and shall produce the withheld verdict, not
|
|
a partial or degraded result: 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 read at all. None shall be
|
|
reported as an empty list carrying a `blocked_reason` other than `unknown`.
|
|
|
|
### REQ-PLUGINS-TASK-DEPS-005: Refresh contract for dependency changes
|
|
|
|
**Intent:** A consumer must know how it learns the graph changed, without the
|
|
event stream becoming a second source of truth for edges.
|
|
|
|
#### Acceptance criteria
|
|
|
|
- **AC-PLUGINS-TASK-DEPS-005.1:** Canvas events shall not carry the dependency
|
|
projection, any edge list, or any edge end's title or state.
|
|
- **AC-PLUGINS-TASK-DEPS-005.2:** Adding or removing a dependency edge shall
|
|
publish a `task.updated` event for both ends of that edge. Delivery to a canvas
|
|
shall follow the existing event scope matching, which for repository and session
|
|
scopes matches on a single identity carried by the event rather than the full
|
|
set the direct-read predicate accepts. The contract shall therefore not promise
|
|
delivery to every canvas whose scope admits an end; the consumer's guarantee is
|
|
`AC-PLUGINS-TASK-DEPS-005.3`.
|
|
- **AC-PLUGINS-TASK-DEPS-005.3:** The documented refresh rule shall be that a
|
|
consumer re-reads the affected tasks after `task.updated`,
|
|
`task.dependencies_resolved`, or `task.dependency_failed`, because none of
|
|
those events identifies which fields changed.
|
|
- **AC-PLUGINS-TASK-DEPS-005.4:** One response shall not be a guaranteed
|
|
transactional snapshot: a consumer shall be documented as unable to assume that a
|
|
task appearing in another task's `blocks` also lists that task in its own
|
|
`depends_on` within the same response.
|
|
- **AC-PLUGINS-TASK-DEPS-005.5:** The refresh rule and the non-snapshot rule shall
|
|
be stated in the public canvas and plugin authoring documentation.
|
|
- **AC-PLUGINS-TASK-DEPS-005.6:** Deriving the projection shall acquire no
|
|
dependency mutation lock, so a read shall neither delay nor be delayed by a
|
|
concurrent edge add or remove, and concurrent reads of the same task shall not
|
|
serialize against each other.
|
|
- **AC-PLUGINS-TASK-DEPS-005.7:** The rule shall also cover a predecessor's
|
|
ordinary state change, which the three events above miss: a predecessor moving
|
|
state while another predecessor is still pending publishes neither
|
|
`task.dependencies_resolved` nor `task.dependency_failed` for the dependent,
|
|
only `task.state_changed` for itself, so the `state` on that dependent's
|
|
`depends_on` entry would otherwise stay stale indefinitely. The documented rule
|
|
shall require a consumer holding a cached task to re-read it on
|
|
`task.state_changed` for any task named in its `depends_on` or `blocks`.
|
|
- **AC-PLUGINS-TASK-DEPS-005.8:** No dependent-side event shall be added for a
|
|
predecessor state change: the event contract is owned elsewhere, the fan-out is
|
|
unbounded in the dependent count, and `AC-PLUGINS-TASK-DEPS-005.2` already
|
|
declines to promise scope-complete delivery. A consumer rule over an
|
|
already-published event is the guarantee instead.
|
|
|
|
## Out of scope
|
|
|
|
- **Dependency writes.** Neither gRPC `UpdateTask` nor the canvas `PATCH` route
|
|
gains an edge field; a consumer mutates edges through the Kandev task API or UI.
|
|
A write contract needs cycle rejection, cross-workspace rejection, and Tasks'
|
|
serialized validate-then-insert path.
|
|
- **A blocked filter on the task list.** The filter keeps workspace, workflow,
|
|
state, and parent only; a consumer pages and filters on `blocked` itself. A
|
|
server-side filter would evaluate a derived, unindexed value: a Tasks decision.
|
|
- **Session reads for canvases.** The canvas data surface exposes only tasks and
|
|
workflows, so a canvas colours a node by task `state` but cannot tell a running
|
|
session from a waiting one. `data/sessions` is a separate extension.
|
|
- **Dependency fields in the event payload.** Excluded by
|
|
`AC-PLUGINS-TASK-DEPS-005.1`: the event projection has no per-entry scope
|
|
evaluation, so edge ends there would bypass `REQ-PLUGINS-TASK-DEPS-003`.
|
|
- **Event routing enrichment.** `AC-PLUGINS-TASK-DEPS-005.2` accepts that a
|
|
repository- or session-scoped canvas may miss a `task.updated` for a task it
|
|
could read directly. Widening routing changes the event contract, owned
|
|
elsewhere; `AC-PLUGINS-TASK-DEPS-005.3` is the guarantee instead.
|
|
- **Transitive closure, path finding, and cycle reporting.** Both lists are
|
|
direct edges only; a consumer composes reachability from what it read.
|
|
- **A transactional snapshot across one response.** Excluded by
|
|
`AC-PLUGINS-TASK-DEPS-005.4`; symmetry would need one transaction across the
|
|
whole batched derivation.
|
|
- **A new capability id.** The projection is a field set on the already-approved
|
|
`api_read:tasks` resource; adding fields changes neither a plugin manifest nor
|
|
its digest, so no re-approval is triggered.
|
|
- **A runtime host-version signal.** Excluded by `AC-PLUGINS-TASK-DEPS-001.16`
|
|
in favour of the install-time `min_kandev_version` floor.
|
|
- **Further fields on the projection or on an edge end.** Considered in Spec and
|
|
deferred as a set: true `depends_on_count` and `blocks_count` beside the
|
|
truncation flags, an end's `archived_at`, `identifier`, or `parent_id`, and the
|
|
edge's own `created_at`. Every one of them is already loaded and then discarded
|
|
by the derivation, so this is a scope decision rather than a cost one. Two
|
|
things make them a later capability instead of a late addition here: each
|
|
edge-end field must be placed on one side of
|
|
`AC-PLUGINS-TASK-DEPS-003.8`'s redaction line, which reopens that whole table,
|
|
and any new field reopens `AC-PLUGINS-TASK-DEPS-001.1` and
|
|
`AC-PLUGINS-TASK-DEPS-001.2`. ADR 0043's additive-only rule means each can ship
|
|
later without breaking a consumer; the carrying cost is that
|
|
`AC-PLUGINS-TASK-DEPS-001.15`'s "within one host version" presence guarantee
|
|
makes every later addition repeat the version-skew ambiguity this contract
|
|
prices once in `AC-PLUGINS-TASK-DEPS-001.16` and
|
|
`AC-PLUGINS-TASK-DEPS-001.17`.
|