Files
my-pi/pi-permission-system/docs/plans/0596-structured-intent-forwarded-wire.md
T

329 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 596
issue_title: "pi-permission-system: carry the structured access intent onto the forwarded-permission wire"
---
# Carry the structured access intent onto the forwarded-permission wire
## Release Recommendation
**Release:** mid-batch — defer (batch "cross-session-intent"); confirm at ship time
This issue is Step 2 of Phase 12 Track A, the middle member of release batch "cross-session-intent" (Steps 1, 2, 3; tail = Step 3, [#597]).
The roadmap step (`docs/architecture/architecture.md`) tags it `Release: batch "cross-session-intent"`, and Step 2 is not the batch tail.
Its plan-execution commits are `feat:`/`test:`/`docs:` on `main`; the whole batch ships together once [#597] lands and cuts the release.
## Problem Statement
When an `ask`-state permission arises in a subagent child with no UI, the extension forwards it up the session tree for a decision.
The gate that raised the ask computed a full `AccessIntent` — for a path-shaped ask, an `AccessPath` carrying the lexical canonical alias set (`matchValues()`) and the canonical boundary form (`boundaryValue()`) — and then discarded it.
`PromptPermissionDetails` and `ForwardedPermissionRequest` carry only display strings (`surface`/`value`), so by the time an ask reaches the serving node the structured facts it needs are unrecoverable.
ADR 0008 (`docs/decisions/0008-cross-session-access-intent.md`, Step 1) settled the contract: **the child owns the facts; the parent owns the judgment.**
The facts are fixed at the origin child and carried unchanged across every hop; no node re-derives them.
This step threads those child-fixed facts from the point of decision (the gate) through the escalation edge (`ParentAuthorizer`) and onto the forwarded wire as the `ForwardedAccessIntent` field ADR 0008 specifies.
It does not yet make serving *consume* the field — that is Step 3 ([#597]).
Threading the intent onto the wire is the structural half of dissolving [#565] items 23 (path re-interpretation at the wrong node; undefined agent-scope semantics); the serving half lands in Step 3.
## Goals
- Declare the `ForwardedAccessIntent` wire schema (per ADR 0008 §2) in `src/authority/permission-forwarding.ts` and add it as an optional field on `ForwardedPermissionRequest`.
- Carry the gate-fixed access facts (`surface`, `matchValues`, `boundaryValue`) from every path and non-path gate onto `PromptPermissionDetails`, so they flow through the runner into the escalation edge.
- Stamp the requester-identity portion (`requesterCwd`, `principal`) at the escalation edge (`ParentAuthorizer`), where session identity is known, and serialize the complete `ForwardedAccessIntent` onto the forwarded request.
- Read the new field tolerantly in `src/authority/forwarding-io.ts` (version-skew: an older child's request without the field still reads and floors to `ask` as today).
- Honor the ADR-0002 string boundary: the wire carries strings only, never an `AccessPath` instance.
- This change is **non-breaking**: it adds an optional field with a tolerant read; no config, schema, default, or observable decision changes on upgrade.
Verify criterion (from the roadmap): `grep -c ForwardedAccessIntent src/authority/permission-forwarding.ts` goes 0 → ≥ 1.
## Non-Goals
- **Serving consumption of the intent** — Step 3 ([#597]) reworks `ServingPolicy`/`forwarded-request-server.ts` to resolve the forwarded intent directly and retires the legacy `(surface, value)` re-derivation.
This step leaves `servingPolicy` in `index.ts` and `forwarded-request-server.ts` untouched; serving still re-derives from display strings, and `grep -c ForwardedAccessIntent forwarded-request-server.ts` stays 0.
- **Removing the `hasDisplayFields` floor** — the display-field escalation floor is Step 3's symptom to dissolve; the display fields (`source`/`surface`/`value`) continue to ride the wire unchanged.
- **Agent-scoped serving evaluation** — `requesterAgentName` graduates to decision-participating only when Step 3 resolves against it; this step carries `principal.agentName` on the wire but changes no resolution behavior.
- **Multi-surface fact set and multi-hop principal identity** — ADR 0008's two explicitly deferred edges; out of scope here.
## Background
Relevant existing modules and how they relate:
- **`src/access-intent/access-path.ts`** — the `AccessPath` value object.
`matchValues()` returns the lexical alias union canonical form (the [#418]/[#486] match set); `boundaryValue()` returns the canonical (symlink-resolved) form or `""`; `value()` the lexical absolute form.
Every path gate already builds one.
- **`src/access-intent/access-intent.ts`** — the gate-emitted `AccessIntent` union (`tool | access-path`).
The `access-path` variant holds the `AccessPath`.
ADR-0002 (`docs/decisions/0002-path-values-string-boundary.md`) keeps the manager string-based: `AccessPath` never crosses into the manager or the wire; producers convert to strings.
- **Gate factories** (`src/handlers/gates/`) — each builds a pure `GateDescriptor`. `path.ts`, `external-directory.ts` (tool surfaces) and `bash-path.ts`, `bash-external-directory.ts` (bash surfaces) hold an `AccessPath`; `tool.ts` holds one for the per-tool path-bearing surfaces (`accessPath?`); `skill-input.ts`/`skill-read.ts` are non-path.
- **`src/handlers/gates/runner.ts`** — `GateRunner.runDescriptor` spreads `descriptor.promptDetails` into `this.prompter.escalate({ requestId, ...descriptor.promptDetails, ... })`.
Anything on `promptDetails` reaches the escalation edge.
- **`src/authority/permission-prompter.ts`** — `PromptPermissionDetails` is the ask payload. `GateDescriptor.promptDetails` is `Omit<PromptPermissionDetails, "requestId">`, so a new optional field on `PromptPermissionDetails` is automatically available on every descriptor's `promptDetails`.
- **`src/authority/approval-escalator.ts`** — `ParentAuthorizer.authorize(details)` builds a `ForwardedPermissionRequest` via `buildForwardedRequest` and writes/polls it.
It already computes `requesterSessionId` (`getSessionId(ctx)`) and `requesterAgentName`.
- **`src/authority/forwarder-context.ts`** — `ForwarderContext`, the narrow read-interface `ExtensionContext` satisfies structurally, plus `getSessionId(ctx)`. `ExtensionContext.cwd` exists (used at `permission-gate-handler.ts:73`, `lifecycle.ts:59`), but `ForwarderContext` does not currently expose it.
- **`src/authority/permission-forwarding.ts` / `forwarding-io.ts`** — the `ForwardedPermissionRequest` type and its tolerant reader `readForwardedPermissionRequest`, which reconstructs an allowlist of known fields with per-field `asX` narrowers (`asUiPromptSource`, `asNullableDisplayString`, `asForwardedSessionApproval`).
Constraint from AGENTS.md / the package skill applied here:
- **ADR-0002 string boundary** — the wire schema carries `string[]`, never `AccessPath`; each gate converts via `matchValues()`/`boundaryValue()` at emit.
- **Architecture-doc convention** — module-tree entries describe current behavior; cite an issue only for an active constraint.
The roadmap Step 2 heading + Mermaid `S2` node get `✅` + a `Landed:` note at implementation completion (not deferred to ship).
- **Tolerant-reader touch point** ([#558]) — `readForwardedPermissionRequest` reconstructs an allowlist, so a new field is silently dropped unless the reader is extended.
That extension is in scope.
## Design Overview
### The fact / identity split
ADR 0008 groups a forwarded ask into *what is being accessed* (fixed at the child gate) and *who/where is requesting* (a property of the requester session).
This plan mirrors that split across the two layers that own each half:
- **The gate emits the access facts** — `surface`, `matchValues`, `boundaryValue`.
Only the gate can produce `matchValues`/`boundaryValue` (they live on the `AccessPath`), and they must not be re-derived downstream.
- **The escalation edge stamps the requester identity** — `requesterCwd` (the session cwd, `ctx.cwd`) and `principal` (`sessionId`, `agentName`). `ParentAuthorizer` already knows both.
This keeps each gate producing only what it genuinely fixes (no per-gate cwd threading) and localizes principal-stamping to the one edge that owns session identity.
### Data shapes
Declared in `src/authority/permission-forwarding.ts` (strings only — ADR-0002):
```typescript
/**
* The child-fixed facts a gate emits: the surface it evaluated and the match
* set it computed. `principal` and `requesterCwd` are stamped at the
* escalation edge, so a gate carries only what it alone can produce.
*/
export interface ForwardedAccessFacts {
/** Gate surface: "path", "external_directory", "bash", a tool name, or a skill name. */
surface: string;
/**
* Child-fixed match set. Path surface: AccessPath.matchValues() (absolute
* cwd-relative canonical). Non-path surface: the already-portable single
* value as a one-element array. Strings only.
*/
matchValues: string[];
/** AccessPath.boundaryValue() for a path surface; null for a non-path surface. */
boundaryValue: string | null;
}
/** The forwarded-wire access intent (ADR 0008 §2): access facts + requester identity. */
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
/** Requester cwd, for provenance/disclosure — never for parent re-derivation. */
requesterCwd: string;
/** Who is requesting. */
principal: {
sessionId: string;
agentName: string;
};
}
```
`ForwardedPermissionRequest` gains an optional field:
```typescript
export type ForwardedPermissionRequest = {
// …existing fields (id, createdAt, requesterSessionId, targetSessionId,
// requesterAgentName, message, source?, surface?, value?, sessionApproval?)…
/**
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
* tolerance: an older child omits it, and the serving node floors to `ask`
* (Step 3). Present on a current child's request for every gate surface.
*/
accessIntent?: ForwardedAccessIntent;
};
```
`PromptPermissionDetails` (`permission-prompter.ts`) gains the gate-facts half only:
```typescript
export interface PromptPermissionDetails {
// …existing fields…
/**
* The child-fixed access facts the raising gate computed. Rides through the
* runner to the escalation edge, which completes them into a
* ForwardedAccessIntent (adding requesterCwd + principal). Absent for a
* serving-node local prompt reconstructed from a forwarded request.
*/
accessIntent?: ForwardedAccessFacts;
}
```
### Gate emission (call sites)
Each gate sets `promptDetails.accessIntent`.
Because `GateDescriptor.promptDetails` is `Omit<PromptPermissionDetails, "requestId">`, no change to the `GateDescriptor` interface in `descriptor.ts` is needed — the facts ride on `promptDetails`, satisfying the issue's "onto the descriptor/details" target.
- **Path surfaces** (`path.ts`, `external-directory.ts`, `bash-path.ts`, `bash-external-directory.ts`): the gate already holds the deciding `AccessPath` (`accessPath` / `worstEntry.path` / the worst uncovered entry's path).
Emit:
```typescript
accessIntent: {
surface: /* "path" | "external_directory" */,
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue() || null,
}
```
For `bash-external-directory.ts`, select the `AccessPath` of the uncovered entry whose `check === worstCheck` (the same entry `preCheck` came from), mirroring how `bash-path.ts` finds `worstEntry`.
- **Per-tool gate** (`tool.ts`, `describeToolGate`): when `accessPath` is present (path-bearing surfaces `read`/`write`/`edit`/`grep`/`find`/`ls`) emit the path-facts form with `surface: gateSurface`; otherwise (bash / MCP / plain tool) emit the single-value form `{ surface: gateSurface, matchValues: [decision.value], boundaryValue: null }`, reusing the already-computed `descriptor.decision.value`.
- **Skill surfaces** (`skill-input.ts`, `skill-read.ts`): non-path — `{ surface: "skill", matchValues: [skillName], boundaryValue: null }`.
### Escalation-edge completion (`ParentAuthorizer`)
`forwarder-context.ts` exposes the session cwd:
```typescript
export interface ForwarderContext {
hasUI: boolean;
ui: PermissionDecisionUi;
cwd: string; // new — ExtensionContext already provides this
sessionManager: { /* …unchanged… */ };
}
/** Reads the current session cwd off `ctx`. */
export function getCwd(ctx: ForwarderContext): string {
return ctx.cwd;
}
```
`ParentAuthorizer.authorize` threads `details.accessIntent` into `buildForwardedRequest`, which completes it (Tell-Don't-Ask: the edge stamps identity from data it already holds; it never asks the wire object to compute anything):
```typescript
// ParentAuthorizer.buildForwardedRequest — illustrative
const accessIntent: ForwardedAccessIntent | undefined = facts
? {
...facts, // surface, matchValues, boundaryValue (child-fixed)
requesterCwd: getCwd(ctx),
principal: { sessionId: requesterSessionId, agentName: requesterAgentName },
}
: undefined;
return {
id, createdAt, requesterSessionId, targetSessionId, requesterAgentName, message,
...(forwarded ? { source, surface, value } : {}),
...(sessionApproval ? { sessionApproval } : {}),
...(accessIntent ? { accessIntent } : {}),
};
```
The full `ForwardedAccessIntent` is what Step 3 will read off the request (`request.accessIntent`) and hand to `resolver.resolve`, using `matchValues` as-is with no parent-side `PathNormalizer` re-derivation.
### Tolerant read (`forwarding-io.ts`)
Add an `asForwardedAccessIntent(value): ForwardedAccessIntent | undefined` narrower alongside the existing `asX` helpers, accepting only a well-formed shape (string `surface`, all-string `matchValues` array, `string | null` `boundaryValue`, string `requesterCwd`, `principal` with string `sessionId`/`agentName`); anything else → `undefined`.
Wire it into `readForwardedPermissionRequest`'s reconstruction block: `accessIntent: asForwardedAccessIntent(parsed.accessIntent)`.
Absent or malformed → `undefined`, which Step 3 floors to `ask`.
### Edge cases
- **Empty boundary** — `AccessPath.boundaryValue()` is `""` for a literal-only path (e.g. a relative bash token after a non-literal `cd`).
Emit `null` for an empty boundary so the wire's `boundaryValue: string | null` is honest (`accessPath.boundaryValue() || null`).
- **Multiple external paths** — the bash external-directory gate carries only the worst (deciding) path's facts; a multi-path/multi-surface fact set is an ADR-deferred edge and floors to `ask` at the serving node.
- **`unknown` requester identity** — `getSessionId`/`requesterAgentName` already fall back to `"unknown"`; `principal` carries those fallbacks verbatim (no new behavior).
## Module-Level Changes
- **`src/authority/permission-forwarding.ts`** — add `ForwardedAccessFacts` and `ForwardedAccessIntent` interfaces; add optional `accessIntent?: ForwardedAccessIntent` to `ForwardedPermissionRequest`. (Satisfies the roadmap grep verify.)
- **`src/authority/forwarding-io.ts`** — add `asForwardedAccessIntent` narrower; import `ForwardedAccessIntent`; wire `accessIntent` into `readForwardedPermissionRequest`.
- **`src/authority/permission-prompter.ts`** — add optional `accessIntent?: ForwardedAccessFacts` to `PromptPermissionDetails`; import `ForwardedAccessFacts` (module already imports `ForwardedSessionApproval` from `permission-forwarding`).
- **`src/authority/forwarder-context.ts`** — add `cwd: string` to `ForwarderContext`; add `getCwd(ctx)` helper.
- **`src/authority/approval-escalator.ts`** — thread `details.accessIntent` from `authorize` into `waitForForwardedApproval` → `buildForwardedRequest`; compose and serialize the full `ForwardedAccessIntent` (`requesterCwd` via `getCwd(ctx)`, `principal` from the already-computed `requesterSessionId`/`requesterAgentName`).
- **`src/handlers/gates/path.ts`** — set `promptDetails.accessIntent` from `accessPath` (surface `"path"`).
- **`src/handlers/gates/external-directory.ts`** — set `promptDetails.accessIntent` from `accessPath` (surface `"external_directory"`).
- **`src/handlers/gates/bash-path.ts`** — set `promptDetails.accessIntent` from `worstEntry.path` (surface `"path"`).
- **`src/handlers/gates/bash-external-directory.ts`** — select the worst uncovered entry's `AccessPath`; set `promptDetails.accessIntent` (surface `"external_directory"`).
- **`src/handlers/gates/tool.ts`** — set `promptDetails.accessIntent`: path-facts when `accessPath` present, else `[decision.value]` single-value form.
- **`src/handlers/gates/skill-input.ts`, `src/handlers/gates/skill-read.ts`** — set `promptDetails.accessIntent` to the `{ surface: "skill", matchValues: [skillName], boundaryValue: null }` form.
- **`test/helpers/forwarding-fixtures.ts`** — `makeForwarderContext` gains a `cwd?` option with a default (e.g. `"/repo"`), so fakes built through it satisfy the widened `ForwarderContext`.
- **Inline `ForwarderContext` fakes** — grep `test/` for inline `{ hasUI, ui, sessionManager }` object literals that do not go through `makeForwarderContext` and add `cwd`.
Candidate files (from the `ForwarderContext` reference grep): `test/authority/forwarding-manager.test.ts`, `test/authority/forwarded-request-server.test.ts`, `test/authority/authorizer.test.ts`, `test/authority/authorizer-selection.test.ts`, `test/composition-root.test.ts` — verify each at implementation time; those using `makeForwarderContext` need no edit.
- **`docs/architecture/architecture.md`** — mark Phase 12 Step 2 complete: `` on the Step 2 heading and the Mermaid `S2` node, add a `Landed:` note.
Update the module-tree entries that now name the mechanism to describe current behavior: `permission-forwarding.ts` (carries the `ForwardedAccessIntent` wire schema), `forwarding-io.ts` (tolerant read of `accessIntent`), `approval-escalator.ts` (`ParentAuthorizer` stamps `requesterCwd`/`principal` and serializes the intent), `permission-prompter.ts` (`PromptPermissionDetails` carries the child-fixed access facts), `forwarder-context.ts` (adds `cwd`/`getCwd`).
The `Forwarded-wire structured intent` metric row now reads ≥ 1; note it in the `Landed:` line but leave the fixed `Baseline` snapshot column unedited (per the package skill).
Contradiction check: no file appears in both Module-Level Changes and Non-Goals — `forwarded-request-server.ts` and `index.ts`'s `servingPolicy` are named in Non-Goals only and are not touched here.
## Test Impact Analysis
1. **New unit tests enabled by the structured field:**
- `forwarding-io` round-trip: a request with a well-formed `accessIntent` reconstructs it; a malformed one drops to `undefined`; an absent one reads as `undefined` (version skew).
- `approval-escalator`: `buildForwardedRequest` stamps `principal` (`sessionId`/`agentName` from the requester) and `requesterCwd` (from `ctx.cwd`) onto `request.accessIntent`; a `details` without `accessIntent` omits the field.
- Per-gate emission: each gate's descriptor carries `promptDetails.accessIntent` with the expected surface and match set (path facts from the `AccessPath` for path surfaces; single-value form for bash/MCP/skill/plain-tool).
These assert on the pure descriptor (no runner needed).
2. **Existing tests that become redundant:** none.
The display-field (`source`/`surface`/`value`) and `sessionApproval` forwarding tests continue to exercise the paths that remain unchanged; nothing is superseded until Step 3 reworks serving.
3. **Existing tests that must stay as-is:** the forwarding round-trip and display-field tolerance tests (they pin the display fields still riding the wire), and every gate's existing resolution/descriptor test (the `access-path` resolve call and `preCheck` are untouched — the facts are read off the same `AccessPath`).
## Invariants at risk
This step touches surfaces earlier phase steps refactored; each invariant below has a pinning test.
- **Display fields still ride the wire** (the [#557]/[#292] non-degraded-broadcast contract) — adding `accessIntent` must not drop `source`/`surface`/`value`.
Pinned by the existing forwarding display-field tests (`test/authority/approval-escalator.test.ts`, `test/authority/permission-forwarding.test.ts`).
Add an assertion that both display fields and `accessIntent` are present on the same request.
- **ADR-0002 string boundary** — no `AccessPath` crosses onto the wire.
Pinned by the `no-restricted-imports` lint on `permission-manager.ts` (untouched) plus a new test asserting `request.accessIntent.matchValues` are strings and `boundaryValue` is `string | null`.
- **Gate resolution unchanged** — the `access-path` intent each gate emits to the resolver, and the resulting `preCheck`/decision, are unchanged (the facts are a read-only projection of the same `AccessPath`).
Pinned by the existing per-gate resolution tests, which must stay green with no edits to their decision assertions.
## TDD Order
1. **`test:` → `feat:` — wire type + tolerant read.**
Add `ForwardedAccessFacts`/`ForwardedAccessIntent` and the optional `ForwardedPermissionRequest.accessIntent` field; add `asForwardedAccessIntent` and wire it into `readForwardedPermissionRequest`.
Test surface: `test/authority/permission-forwarding.test.ts` (or the forwarding-io test) — round-trip well-formed / malformed / absent.
The type and reader land together (the reader references the type).
Commit: `feat(pi-permission-system): declare ForwardedAccessIntent wire schema with tolerant read (#596)`.
2. **`feat:` — escalation-edge serialization + prompt-details facts + context cwd.**
Add `cwd`/`getCwd` to `forwarder-context.ts`; add `accessIntent?: ForwardedAccessFacts` to `PromptPermissionDetails`; thread `details.accessIntent` through `ParentAuthorizer` and stamp `requesterCwd`/`principal` onto `request.accessIntent`.
Update `makeForwarderContext` and every inline `ForwarderContext` fake in the same commit (the widened interface breaks them at compile time).
Test surface: `test/authority/approval-escalator.test.ts`.
Commit: `feat(pi-permission-system): serialize the child-fixed access intent onto the forwarded request (#596)`.
3. **`feat:` — tool-surface path gates emit facts.**
`path.ts` + `external-directory.ts`.
Test surface: their gate tests.
Commit: `feat(pi-permission-system): emit access-intent facts from the tool path gates (#596)`.
4. **`feat:` — bash-surface path gates emit facts.**
`bash-path.ts` + `bash-external-directory.ts` (worst-entry `AccessPath` selection).
Commit: `feat(pi-permission-system): emit access-intent facts from the bash path gates (#596)`.
5. **`feat:` — per-tool gate emits facts.**
`tool.ts` (path form when `accessPath` present; single-value form otherwise).
Commit: `feat(pi-permission-system): emit access-intent facts from the per-tool gate (#596)`.
6. **`feat:` — skill gates emit facts.**
`skill-input.ts` + `skill-read.ts` (single-value skill form).
Commit: `feat(pi-permission-system): emit access-intent facts from the skill gates (#596)`.
7. **`docs:` — mark Step 2 complete + refresh module-tree entries.**
`docs/architecture/architecture.md`: `` heading + `S2` node, `Landed:` note, updated module-tree entries for the touched `authority/` modules.
Commit: `docs(pi-permission-system): mark Phase 12 Step 2 complete (#596)`.
Each gate step (36) is independently type-safe: adding an optional `promptDetails.accessIntent` is additive, and the field is not yet consumed for any decision, so no cross-module compile break forces gates into one commit.
Step 2's `cwd` addition is the one interface tightening; its fixture updates ride the same commit.
## Risks and Mitigations
- **Silent field drop on read** ([#558]) — the tolerant reader reconstructs an allowlist, so an unwired field never round-trips.
Mitigation: Step 1 wires `asForwardedAccessIntent` and tests the round-trip explicitly.
- **`AccessPath` leaking onto the wire** (ADR-0002 violation) — mitigation: gates convert to strings at emit; a Step-1 test asserts the serialized shape is strings only; the existing `permission-manager.ts` import lint is unaffected.
- **Interface tightening breaks fakes** — widening `ForwarderContext` with a required `cwd` breaks inline fakes at compile time.
Mitigation: centralize via `makeForwarderContext`'s default and grep `test/` for inline constructions in the same commit (the AGENTS.md fixture-grep rule for tightened shared types).
- **Over-reaching into Step 3** — the temptation is to also make serving consume the field.
Mitigation: Non-Goals fences `forwarded-request-server.ts`/`servingPolicy`; the serving-read metric stays 0 until [#597].
- **Empty boundary ambiguity** — a literal-only path has `boundaryValue() === ""`.
Mitigation: emit `null` for an empty boundary so the wire distinguishes "no canonical" cleanly.
## Open Questions
- **Whether `principal` should reuse the top-level `requesterSessionId`/`requesterAgentName` rather than nest a copy.**
Resolved for this plan: nest a self-contained `principal` per ADR 0008 §2, since Step 3 reads `intent.principal.agentName` and a self-contained fact object is cleaner than reaching across the request; the top-level fields remain for routing/display and backward compatibility.
No follow-up needed.
- No deferred follow-up issues: Step 3 ([#597]) already exists as the serving-consumption step, and the ADR's two deferred edges are recorded in ADR 0008.
[#292]: https://github.com/gotgenes/pi-packages/issues/292
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#486]: https://github.com/gotgenes/pi-packages/issues/486
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#558]: https://github.com/gotgenes/pi-packages/issues/558
[#565]: https://github.com/gotgenes/pi-packages/issues/565
[#597]: https://github.com/gotgenes/pi-packages/issues/597