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

519 lines
28 KiB
Markdown

---
id: plugins-task-dependency-projection-design
title: Task dependency projection on the plugin data API
status: draft
system: plugins
requirements:
- REQ-PLUGINS-TASK-DEPS-001
- REQ-PLUGINS-TASK-DEPS-002
owners:
- kandev
created: 2026-09-17
last_updated: 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](task-dependency-edge-ends.md), 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](task-dependency-response-bounds.md). 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](task-dependency-refresh.md), 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](../../tasks/system-design/task-dependencies.md). This design
consumes that derivation and adds no second definition of it.
Adjacent contracts used but not owned:
- [Isolated web-app contributions](isolated-web-app-contributions.md) — the
canvas browser data protocol, its scope binding, response limits, and event
stream.
- [Capability approval](capability-approval.md) — the effective-authority
intersection behind `api_read:tasks`.
- [Task completion](../../tasks/system-design/task-completion.md) — what makes a
predecessor resolved.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-PLUGINS-TASK-DEPS-001` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow) |
| `REQ-PLUGINS-TASK-DEPS-002` | [Failure and recovery](#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](task-dependency-edge-ends.md#edge-ends-outside-the-response).
3. **Fail closed on every derivation-failure path**, in
[Failure and recovery](#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](task-dependency-edge-ends.md#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](task-dependency-response-bounds.md#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](task-dependency-response-bounds.md#response-bounds-by-flow)
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](task-dependency-refresh.md#events-and-refresh)),
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](#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](#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](task-dependency-refresh.md#documentation)'s, and
four are [response bounds](task-dependency-response-bounds.md#documentation)'.
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.
## Related decisions
- [ADR 0043 — Plugin host data API](../../../decisions/0043-plugin-host-data-api.md)
- [Isolated web-app contributions](isolated-web-app-contributions.md)
- [Task dependencies](../../tasks/system-design/task-dependencies.md)
- [Dependency edge ends](task-dependency-edge-ends.md)
- [Response bounds](task-dependency-response-bounds.md)