Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
12 KiB
id, title, status, system, requirements, owners, created, last_updated
| id | title | status | system | requirements | owners | created | last_updated | |||
|---|---|---|---|---|---|---|---|---|---|---|
| plugins-task-dependency-edge-ends-design | Dependency edge ends on the plugin data API | draft | plugins |
|
|
2026-09-17 | 2026-09-17 |
Dependency edge ends on the plugin data API System Design
Purpose and boundaries
This design owns what an edge end is allowed to say: which ends a plugin task
read may describe (REQ-PLUGINS-TASK-DEPS-003) and what an end reports when the
response does not contain it (REQ-PLUGINS-TASK-DEPS-006). It is separated from
the task dependency projection because the two
are reviewed against different risks, and because these rules are the part that
must not be traded away when the projection's shape changes.
It does not own the projection's field set, its derivation, its ordering, its bounding rules, or its event behaviour. Those are the projection design's, and this design changes none of them: redaction empties two fields on an entry, and the dangling-edge drop removes an entry whose task no longer exists. Neither changes the verdict.
Adjacent contracts used but not owned:
- Task dependency projection — the field set, the edge-end ref type, and the rule fixing which responses attach the projection.
- Response bounds — the query bound this rule's query-free admission test is written against.
- Isolated web-app contributions — the canvas scope binding this rule evaluates against.
- Capability approval — the effective-authority
intersection behind
api_read:tasks.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-PLUGINS-TASK-DEPS-003 |
Redaction rule |
REQ-PLUGINS-TASK-DEPS-006 |
Edge ends outside the response |
Redaction rule
The projection adds no capability. It is a field set on the already-approved
api_read:tasks resource; adding fields changes neither a plugin manifest nor
its recorded digest, so no re-approval is triggered and the effective-authority
intersection is unchanged.
The one new authority question is the edge end. An end carries another task's title and state, and a task-scoped canvas is refused that task directly, so an unredacted ref would hand it resource content the scope denies. Redaction resolves it:
- The admission test for an end is the same scope predicate that decides direct
readability, so there is one rule and no second notion of visibility to keep in
step. It is evaluated only over data the response has already loaded -- a
read per edge end would be a per-task query and would break the bound in
AC-PLUGINS-TASK-DEPS-004.1. An end whose predicate inputs are not in hand is treated as not admitted, so the query bound and the scope rule fail in the same direction rather than trading against each other. - An end the scope would not admit keeps
idand, on a predecessor,status.titleandstateare emptied. statusis retained deliberately. It is the three-value resolution verdict, and the aggregate it feeds,blocked_reason, is already about the bound task and already visible.stateis dropped because it is the richer raw task state, which is a genuine addition about a task the scope denies.- The entry is emptied rather than dropped, so the edge's existence and the list
length stay truthful. A canvas can already infer that an edge exists from
blocked, so hiding the entry would make the count wrong without hiding anything. - No flag is added to tell a redacted entry apart, and consumers are documented
as rendering an empty
titleas an unlabelled node. The design does not claim the two are indistinguishable: a task that genuinely has an empty title still reports a non-emptystate, and a redacted entry reports neither. The flag is omitted for vocabulary, not concealment -- its false value invites reading as an authority statement, and what it carries is diagnostic only.
By scope kind, the query-free rule resolves as follows, and admits no other end
(AC-PLUGINS-TASK-DEPS-003.8):
| Scope | Admits | Why it is decidable without a read |
|---|---|---|
| Instance | Every end | The predicate admits every task; nothing to check. |
| Workspace | An end whose workspace is known equal to the bound workspace | The ref carries the end's workspace; see below. |
| Task | No end | An end is never the bound task itself. |
| Repository | An end also returned in the response as a directly readable task | The predicate needs the task's repository set, which is a per-task read. |
| Session | An end also returned in the response as a directly readable task | The predicate needs the task's session list, which is a per-task read. |
Where the end's workspace comes from. The admission test may issue no read, so the workspace must arrive with the derivation or not at all. It does: resolving edge-end detail already loads the full task row for every end in the batch in one query, and that row carries the workspace id, which the derivation discards today when it collapses the row into the slim ref. Carrying it costs no extra query, and is change 4 of the five listed under Edge order.
Which type carries it, and where it is dropped. The carrier is the edge-end ref type itself, at every in-process layer: the shared derivation's ref struct gains a workspace-id field, and the mirrored SDK ref type declares the same field. It is dropped at exactly two points, both of them encoders:
- The ref's
toProtomapping, which assigns no proto field for it, because the ref message declares none. - The canvas JSON adapter's field set, which emits
id,title,stateandstatusfor an entry and nothing else.
So "never encoded on either wire" means dropped on the way out of the SDK type,
not dropped from it. The canvas adapter sits downstream of the SDK mapping and
reads the workspace off the SDK ref it already holds, which is what makes
AC-PLUGINS-TASK-DEPS-003.8's workspace row decidable at the one layer that holds
the canvas binding; it then drops the field while building the JSON entry. A
design that dropped the field at the SDK boundary instead would leave the adapter
with no predicate input at all.
Naming the carrier matters because the alternative is not a slower implementation
but a quietly different contract: without it,
AC-PLUGINS-TASK-DEPS-003.3's "predicate inputs unavailable, so not admitted"
becomes the only legal outcome, workspace scope collapses into the repository and
session rule, and every out-of-page end is redacted while every acceptance
criterion still passes as literally written.
Publishing the field instead of dropping it is rejected for the reason redaction exists: it would hand every canvas the workspace of tasks it cannot read.
The repository and session rows are the load-bearing ones. Their predicates resolve a task's repositories and sessions with a read per task, which the admission test may not issue, so the only end they can admit for free is one the response already proved readable by including it. This under-admits: an end the canvas could fetch directly may still be redacted. That is the correct direction for a scope rule to err, and the cost is a title, not an edge -- the entry, the count, and the verdict are unaffected.
The workspace row previously read "redacts nothing, since an edge cannot cross a
workspace". That justification does not hold. The cross-workspace check lives in
the write validator, and it does not reject a pair in which either end carries
no workspace, so a stored edge can name an end whose workspace is empty or
different. The validator is therefore not a blanket exemption
(AC-PLUGINS-TASK-DEPS-003.4), and workspace scope admits on known equality
rather than on assumption.
The gRPC surface redacts nothing (AC-PLUGINS-TASK-DEPS-003.5). It has no canvas
scope to redact against and no narrower binding to enforce. The reason is not a
workspace filter: Tasks().Get applies none at all -- it resolves a task by id
and returns it -- and the write and preview RPCs reach tasks by id too, so only
Tasks().List narrows by workspace. The correct ground is disclosure parity. A
caller holding api_read:tasks can already read any of those tasks directly by
id, so an edge end carrying that task's title and state reveals nothing the
surface withholds; redacting there would cost a title and buy no
confidentiality.
Edge ends outside the response
A graph built from one page has ends the page does not contain. Three cases, all of which resolve without a second contract:
-
Archived end. An archived predecessor is
pending, neverresolved, because archival is neither success nor failure. The default task list hides archived tasks, so such an end is named by the ref but absent fromitems. A non-redacted ref carries id, title, and state, which is enough to render the node; a consumer needing the whole task reads it by id or lists withinclude_archived=true. A read by id is not filtered by archived state, so that path works. -
Out-of-page end. Same shape as the archived case, for an end that simply fell on another page. No special handling.
-
Removed end. An edge whose predecessor row is gone is dropped from
depends_onand does not contribute to the verdict, so a failed cleanup cannot block a dependent forever.The dependent direction is not symmetric today, and this is change 2 of the four the projection design enumerates in the shared derivation. The upstream dangling-edge drop is applied to the predecessor list only -- its regression test asserts on that list alone -- so an edge whose dependent row is gone still yields an entry, carrying the derivation's internal
missingmarker as its status. Emptyingstatusonblocksentries at the mapping layer keeps that marker off the wire and closes the public vocabulary, but it leaves a ref with a dead id in the list, whichAC-PLUGINS-TASK-DEPS-006.4forbids. The drop must therefore be applied to both directions in the shared derivation, and its regression test extended to assert onBlocksas well.
Test strategy
- Scope redaction: per scope kind against the table above -- instance admitting every end, workspace admitting only a known-equal workspace (including an end whose workspace is empty, which is redacted), task scope redacting every end, repository and session admitting only an end the same response returns as a task. Redaction leaves the verdict, count, and order unchanged, and issues no additional read, asserted with the query-count test's counting fake.
- The workspace carrier survives to the adapter: a canvas-surface read on a workspace-scoped canvas admits an out-of-page end in the bound workspace and redacts one outside it, which fails if either encoder's drop point is moved upstream of the adapter.
- Neither wire carries the workspace of an edge end: the proto ref message has no such field, and the canvas JSON entry has exactly the four documented keys.
- The gRPC surface redacts nothing: the same task read over gRPC returns every
end's
titleandstate, on the single-task read as well as the list read. - Dangling ends in both directions: an edge whose predecessor row is gone and one
whose dependent row is gone are both omitted, and no entry carries the internal
missingmarker. - Vocabulary: no entry reports a non-empty status outside the closed set,
including for an absent end;
blocksentries report an empty one. - Archived predecessor reports
pending, appears in the ref, and is absent from a default list.