From 377b0b201e138ebfd34a5d0baf21d3028c5212ad Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:19:09 +0000 Subject: [PATCH] docs: freeze generic plugin host boundary (#3175) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- Makefile | 2 +- ...2026-08-31-generic-plugin-host-boundary.md | 486 ++++++++++++++++++ docs/decisions/INDEX.md | 1 + docs/specs/plugins/requirements/plugins.md | 5 + .../specs/plugins/system-design/plugins-02.md | 19 + scripts/generic-plugin-host-boundary.test.mjs | 295 +++++++++++ 6 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/2026-08-31-generic-plugin-host-boundary.md create mode 100644 scripts/generic-plugin-host-boundary.test.mjs diff --git a/Makefile b/Makefile index d06981535f..5cf25a1450 100644 --- a/Makefile +++ b/Makefile @@ -575,7 +575,7 @@ test-scripts: @node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs @python3 .github/scripts/release-workflow-contract_test.py @node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs scripts/release/update-scoop-bucket.test.mjs - @node --test scripts/validate-public-docs.test.mjs + @node --test scripts/validate-public-docs.test.mjs scripts/generic-plugin-host-boundary.test.mjs .PHONY: test-e2e test-e2e: build-backend build-backend-linux-helpers build-web-e2e build-e2e-plugin-package diff --git a/docs/decisions/2026-08-31-generic-plugin-host-boundary.md b/docs/decisions/2026-08-31-generic-plugin-host-boundary.md new file mode 100644 index 0000000000..bd40fa974e --- /dev/null +++ b/docs/decisions/2026-08-31-generic-plugin-host-boundary.md @@ -0,0 +1,486 @@ +# ADR-2026-08-31-generic-plugin-host-boundary: Generic plugin Host boundary and capability approvals + +**Status:** proposed +**Date:** 2026-08-31 +**Area:** backend, frontend, protocol, security, workflow + +## Context + +The Coordinator product is moving into a separately released plugin. Its policy, +scheduling, durable state, reconciliation, prompts, reports, tools, and product UI +belong to that plugin, but it still needs a supported way to observe and change +Kandev state. The current Host contract is too small for that use case, and its +manifest declarations are not an operator approval ledger. + +The preserved migration baselines prove useful shapes but also show the boundary +failure. Host PR [#2793](https://github.com/kdlbs/kandev/pull/2793) at +`afd2b699bfe9b6af9353ea01728582f61a7be2be` combines generic conversation and +relation work with Coordinator-specific principals, grants, storage, and UI. Plugin +PR [#1](https://github.com/yattdev/kandev-plugin-coordinator/pull/1) at +`5bfdbcf7d9608d1210453f95ebfc8f66c3179225` keeps product state in the plugin but +uses dispatch bookkeeping rather than a durable intent/outbox protocol. Both heads +are read-only migration evidence, not branches to extend or close here. + +This decision freezes generic public names and invariants so later implementation +units do not invent Coordinator concepts in core. It defines contracts; it does not +implement them. + +## Decision + +### Ownership and sanctioned call chain + +Coordinator identity, policy, scheduling, durable SQLite state, reconciliation, +prompts, reports, namespaced agent tools, and Overview/Chat/Reports/Policy/Audit UI +remain plugin-owned. Kandev owns only generic Host contracts, shared domain +commands and queries, capability approval, audit/event infrastructure, and generic +UI/config primitives. + +The only production call chain is: + +```text +plugin-managed agent + -> namespaced plugin-owned agent tool + -> plugin backend + -> capability-gated generic Host RPC + -> shared Kandev domain command or query + -> repository and event infrastructure +``` + +An ordinary task agent may reach the same named domain command or query through a +global MCP adapter. Host RPC and MCP request DTOs may differ, but neither adapter +owns workflow, WIP, authorization, ownership, idempotency, lifecycle, relation, +task-run, pending-transition, or audit rules. + +A plugin must never integrate through ambient/global Kandev MCP, private or +undocumented REST, direct database access, shelling into Kandev, or prompt/comment +text treated as authority. Core must not gain a Coordinator table, field, profile, +role, principal, grant, setting, tool, or audit vocabulary. + +### Exact DTOs and result vocabulary + +Every **new exact** Host request introduced by this ADR carries `request_id` +and `workspace_id`. Operations that need an explicit approval-generation +precondition also carry `capability_revision`; ordinary calls use current +Host-side authorization. Every new exact writer also carries `idempotency_key`, exact target +resource versions, and a `PendingTransitionGuard` that is either +`expect_none` or `{ pending_transition_id, resource_version }`. An +agent-originated writer additionally carries `ManagedExecutionProvenance` with +`conversation_id`, `session_id`, and `execution_generation_token`. The Host +derives installation identity; callers cannot supply or override it. + +Every list request carries `Page { limit, cursor, snapshot_version }`; responses +carry `PageInfo { next_cursor, has_more, snapshot_version }`. Cursors are opaque +and bound to workspace, filters, snapshot version, capability revision, and +installation. Every mutable/readback resource carries `resource_version`. + +This requirement does not retroactively add fields to any shipped `v1` request. +The legacy compatibility table below names the only compatibility path. A new +exact RPC has a distinct `*Exact` wire method, DTO, and `host.v2.*` capability; +it never accepts a missing workspace or revision as a synthetic legacy approval, +and it never reuses `api_read:*` or `api_write:*` as authority. Implementations +reject absent exact fields as `INVALID`, except that an existing v1 method +continues to use its listed v1 behavior. The Host derives `installation_id` in +both cases; it is never caller input. + +Writers return a typed `HostCommandResult` with one of these stable states: + +| State | Meaning | +| --- | --- | +| `APPLIED` | The command committed and the response contains authoritative readback. | +| `ALREADY_APPLIED` | The same idempotency identity already committed with the same canonical digest. | +| `NO_CHANGE` | Preconditions held, but authoritative state already matched the requested outcome. | +| `CONFLICT` | A target version, pending-transition predicate, directive state, or generation changed. | +| `DENIED` | Current approval, provenance, or immutable Human policy rejects the operation. | +| `NOT_FOUND` | The exact workspace-scoped target does not exist or is not visible to the principal. | +| `INVALID` | The request is structurally or semantically invalid. | +| `UNSUPPORTED` | The Host version or provider cannot satisfy the named contract. | +| `RATE_LIMITED` | Provider rate budget prevents the operation; reset metadata is present when known. | +| `UNAVAILABLE` | A required service is temporarily unavailable and no side effect is claimed. | +| `PARTIAL` | Only a contract that explicitly defines durable progress may use this; completed receipts are included. | + +`HostCommandResult` also carries `audit_id`, `result_resource_version`, and an +authoritative readback or readback reference. Each stable reason has exactly one +parent state and retry model: + +| Parent state | Stable reasons | Client behavior | +| --- | --- | --- | +| `DENIED` | `STALE_CAPABILITY_REVISION`, `CAPABILITY_REVOKED`, `HUMAN_RESERVED`, `MISSING_CAPABILITY_APPROVAL`, `PROVENANCE_REJECTED` | Fail closed. Park the action and require Human approval, reconfiguration, or an eligible fresh installation; never retry automatically. | +| `CONFLICT` | `STALE_RESOURCE_VERSION`, `PENDING_TRANSITION_CONFLICT`, `EXECUTION_GENERATION_FENCED`, `DIRECTIVE_REVOKED`, `DIRECTIVE_EXPIRED`, `DIRECTIVE_ALREADY_RESOLVED`, `IDEMPOTENCY_MISMATCH` | Do not repeat the side effect. Read back authoritative state; retry only with a newly constructed operation when that state permits it. | +| `NOT_FOUND` | `WORKSPACE_TARGET_NOT_FOUND` | Stop against this identity and refresh the caller’s projection. | +| `INVALID` | `MISSING_EXACT_FIELD`, `MALFORMED_PRECONDITION`, `UNSUPPORTED_LEGACY_SHAPE` | Correct the request or use the documented v1 method; no automatic retry. | +| `UNSUPPORTED` | `HOST_VERSION_UNSUPPORTED`, `PROVIDER_CONTRACT_UNSUPPORTED`, `UNKNOWN_RESULT_VALUE` | Park pending an upgrade or a separately supported contract. | +| `RATE_LIMITED` | `PROVIDER_RATE_LIMITED` | Retry only after the supplied reset/Retry-After boundary. | +| `UNAVAILABLE` | `DEPENDENCY_UNAVAILABLE` | Retry the same idempotency identity using bounded backoff. | + +Transport status codes may classify the same outcome, but clients branch on the +typed result and tolerate unknown enum values as `UNSUPPORTED`. + +### H6 capability context bootstrap + +Before the first exact call, a plugin invokes `GetCapabilityContext` on its +connection-bound Host. The request carries only `request_id`; the Host derives +the installation identity from the plugin connection. The response reports the +Host contract version, context revision, approved workspace capability +revisions, capability IDs, manifest digest, and approval status. It grants no +authority. Each exact operation still uses current Host-side authorization and +supplies a capability revision when that operation requires an explicit +approval-generation precondition. + +The Host emits `capability-context-changed` after an approval, reduction, +revocation, upgrade, or policy change. A stale exact request returns +`DENIED/STALE_CAPABILITY_REVISION`; the plugin constructs a new request rather +than retrying the side effect. An empty workspace list is a valid degraded +state. + +### Legacy v1 compatibility fence + +The current `kandev.plugin.v1.Host` surface is a shipped, manifest-declaration +API, not the H6 approval protocol. It remains callable only with the following +named method/capability behavior; its omitted fields mean **v1 compatibility**, +not an approval revision. A v1 call cannot invoke a new exact writer, issue a +directive, consume a continuation, or gain a `host.v2.*` capability. + +| Existing v1 methods | Existing authority | Absent-field behavior | H0 boundary | +| --- | --- | --- | --- | +| `GetState`, `SetState`, `DeleteState`, `ListState`; `GetSecret`, `SetSecret`, `DeleteSecret`; `RevealSecret`; `EmitEvent`; `InvokeUtilityAgent` | Their existing manifest declarations (`state`, `secrets`, `events`, `agent_invoke`) | No workspace or capability revision is inferred. Existing plugin-scoped semantics continue. | Not an H1-H5 exact operation; no H6 authority is created. | +| `GetConfig(GetConfigRequest {})` | Ungated, plugin-global operator configuration | The empty request remains empty and has no workspace, approval, or capability revision. It returns only the calling plugin’s own config under the existing secret policy. | It is explicitly outside H6. A future scoped configuration operation, if needed, must be a new versioned successor and cannot change `GetConfig`. | +| `ListTasks`, `GetTask`, `ListWorkspaces`, `ListWorkflows`, `ListWorkflowSteps`, `ListAgentProfiles`, `ListExecutorProfiles`, `ListRepositories`, `ListSessions`, `ListSessionCodeStats`, `ListMessages`, `ListPendingInteractions`, `GetInteraction` | The exact existing `api_read:` declaration documented in `plugin.proto` | Existing request fields remain optional/as shipped; no workspace or revision is synthesized. | H2 exact projections use separately named `*Exact` methods and `host.v2.read:*` capabilities. v1 reads cannot satisfy an H0 exact-read receipt. | +| `CreateTask`, `UpdateTask`, `MoveTask`, `SendMessage`, `PreviewPluginOwnedTaskTree`, `DeletePluginOwnedTaskTree`, `RespondToPermission`, `AnswerClarification`, `CancelClarification` | The exact existing `api_write:` declaration documented in `plugin.proto` | Existing request fields remain as shipped; no workspace, H6 revision, guard, or provenance is invented. | They are not H3/H4/H5 exact writers. A plugin needing an H0 writer must call the new exact method with a `host.v2.write:*` approval. | + +No synthetic legacy revision exists. Existing installed plugins keep only the +v1 behavior they already had, subject to their existing manifest declaration and +ordinary lifecycle. A new installation using a legacy v1 writer additionally +requires a server-side compatibility grant scoped to that installation; the +grant cannot authorize any new exact capability or bypass H6/C1/C2 safeguards. + +### Public Host surface inventory + +The wire service remains `kandev.plugin.v1.Host`. The namespace column is the +permanent SDK shape; the RPC names in the method column are permanent wire names. +`HostReadReceipt` identifies the installation, workspace, capability revision, +request, and snapshot read. `HostAuditReceipt` additionally records before/after +versions, idempotency identity, provenance when present, and the command result. + +| Unit | Permanent service / namespace | Permanent method names | Request DTO | Response and result states | Page, version, idempotency, and preconditions | Declared capability | Audit / event identity | Shared domain owner | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| H1 managed conversations | `Host.ManagedAgentConversations` | `EnsureManagedAgentConversationExact`; `DispatchManagedAgentConversationExact`; `GetManagedAgentConversationStatusExact`; `ListManagedAgentConversationsExact`; `DeleteManagedAgentConversationExact` | Corresponding `*ExactRequest` DTOs | `ManagedAgentConversationResult`: common states plus `CONFIGURATION_REQUIRED`, `BUSY`, `FENCED`, and descriptor/status readback | List uses `Page`; every descriptor has `resource_version`; writers carry idempotency, capability revision, exact descriptor version, transition guard, and generation token where continuing a run | `host.v2.read:managed_agent_conversations`; `host.v2.write:managed_agent_conversations` | `HostReadReceipt`; `HostAuditReceipt`; `managed_agent_conversation.*` with `audit_id` | `ManagedAgentConversationQuery` and `ManagedAgentConversationCommand`; H1 | +| H1 generic chat UI | `host.ui.WorkspaceAgentChat` | `WorkspaceAgentChat(props)` | `WorkspaceAgentChatProps { workspaceId, conversationId, resourceVersion, readOnly?, onStatus? }` | Host-owned loading, ready, configuration-required, fenced, error, and read-only states | The component consumes H1 versions and never receives authority through props | No new capability; backing H1 calls remain gated | UI lifecycle generation and H1 receipts | Host UI primitive; H1 | +| H2a topology and runtime projections | `Host.Workspaces`, `Host.Workflows`, `Host.Tasks`, `Host.Sessions`, `Host.Interactions` | `ListWorkspacesExact`; `ListWorkflowsExact`; `ListWorkflowStepsExact`; `ListTasksExact`; `GetTaskExact`; `ListSessionsExact`; `ListPendingInteractionsExact`; `GetInteractionExact` | New exact workspace-explicit filters plus `Page`; exact getters use workspace plus id | Bounded projection DTOs with per-item `resource_version`; pending interactions preserve their typed terminal states | Every list uses page/snapshot binding; no read filter grants authority | `host.v2.read:workspaces`, `host.v2.read:workflows`, `host.v2.read:tasks`, `host.v2.read:sessions`, `host.v2.read:interactions` | `HostReadReceipt`; no mutation event | `WorkspaceTopologyQuery`, `TaskProjectionQuery`, `SessionProjectionQuery`, `InteractionQuery`; H2a | +| H2b communications | `Host.Messages`, `Host.TaskInbox`, `Host.TaskDirectives` | `ListSanitizedMessagesExact`; `ListTaskInboxExact`; `GetTaskDirectiveExact`; `ListTaskDirectivesExact` | New exact workspace/task/session filters, sanitization level, directive id, `Page` | Sanitized message, durable inbox item, and directive status/readback; terminal directive states remain queryable | Snapshot-bound pages; items carry resource versions and canonical digests | `host.v2.read:messages`, `host.v2.read:task_inbox`, `host.v2.read:task_directives` | `HostReadReceipt`; no mutation event | `TaskCommunicationQuery`, `TaskInboxQuery`, `TaskDirectiveQuery`; H2b | +| H2c relations and pending transitions | `Host.TaskRelations`, `Host.TaskTransitions` | `GetTaskRelationsExact`; `ListTaskRelationsExact`; `ListPendingTaskTransitionsExact` | New exact task/workspace filters plus `Page` | Relation graph with edge versions and child/dependency closure; pending transition with predicate and resource version | Snapshot-bound pages; versions are consumable by H3b/H3c/H5 | `host.v2.read:task_relations`, `host.v2.read:task_transitions` | `HostReadReceipt`; no mutation event | `TaskRelationQuery`, `TaskTransitionQuery`; H2c | +| H2d exact change evidence | `Host.ChangeRequests` | `GetChangeRequestEvidenceExact`; `ListChangeRequestEvidenceExact` | Canonical provider, workspace, repository, base, head, immutable head/merge-ref identity, and `Page` for the result or a named nested collection | Exact checks/jobs, review, thread, mergeability, divergence, rate budget, and receipt projections described below | Snapshot/resource version binds provider, repository, base, immutable head, merge ref, provider connection generation, and nested cursors | `host.v2.read:change_requests` | `HostReadReceipt` plus provider read receipt | `ChangeEvidenceQuery`; H2d | +| H2e terminal provenance | `Host.TerminalResources` | `GetTerminalResourceProvenanceExact` | Exact workspace/task, optional closure depth, and `Page` plus section key for every nested collection | Canonical change disposition, resources, consumers, closure, interactions, cleanup ownership, and prior receipts described below | One exact provenance `resource_version`; every nested page binds to it; no cleanup authority is implied | `host.v2.read:terminal_resources` | `HostReadReceipt`; no mutation event | `TerminalResourceQuery`; H2e | +| H3a messaging and directives | `Host.Messages`, `Host.TaskDirectives` | `SendMessageExact`; `IssueTaskDirectiveExact`; `ResolveTaskDirectiveExact` | `SendMessageExactRequest`, `IssueTaskDirectiveExactRequest`, `ResolveTaskDirectiveExactRequest` | Common results; directive readback includes pending, resolved, revoked, expired, and generation-fenced states | Capability revision, idempotency, target/session versions, transition guard; agent origin adds managed execution provenance; directive semantics are frozen below | `host.v2.write:messages`, `host.v2.write:task_directives` | `HostAuditReceipt`; `message.dispatched`; `task_directive.issued\|resolved\|revoked` | `MessageCommand`, `TaskDirectiveCommand`; H3a | +| H3b task writers | `Host.Tasks` | `CreateTaskExact`; `MoveTaskExact`; `UpdateTaskExact`; `SetTaskLabelsExact` | Exact create/placement, move, field-mask, and label requests | Common results with authoritative task readback | Capability revision when required, idempotency, exact task/workflow/step versions as applicable, transition guard, optional managed execution provenance | `host.v2.write:tasks` | `HostAuditReceipt`; existing `task.*` events gain `audit_id` | `TaskCommand`; H3b | +| H3c relation writers | `Host.TaskRelations` | `AddTaskRelationExact`; `RemoveTaskRelationExact` | Exact workspace, source, target, relation kind, edge/endpoint versions | Common results with authoritative relation-graph readback | Capability revision, idempotency, endpoint and edge versions, transition guard, optional managed execution provenance | `host.v2.write:task_relations` | `HostAuditReceipt`; `task_relation.added\|removed` | `TaskRelationCommand`; H3c | +| H3d provider actions | No Host writer is approved in H0 | No RPC or SDK method is reserved | None | `UNSUPPORTED` if a plugin assumes one | None | None | None | Deferred independently as recorded below; H3d | +| H4 exact execution lifecycle | `Host.TaskRuns`, `Host.Sessions` | `EnsureTaskRunExact`; `RecoverSessionExact` | Exact task/workstep/session, desired execution identity, prompt/action digest, dispatch idempotency, expected versions | Common results plus one admissible `execution_generation_token` and authoritative run/session readback | Capability revision, idempotency, exact task/workstep/session/run versions, transition guard; replacement must fence the prior generation first | `host.v2.write:task_runs`, `host.v2.write:sessions` | `HostAuditReceipt`; `task_run.ensured`; `session.recovered`; generation-fenced lifecycle events | `TaskRunCommand`, `SessionRecoveryCommand`; H4 | +| H5 pending transitions | `Host.TaskTransitions` | `CancelPendingTaskTransitionExact` | Exact transition id/version and cancellation reason | Common results plus optional single-use continuation token and authoritative readback | Cancel requires capability revision, idempotency, exact transition version, and current target version | `host.v2.write:task_transitions` | `HostAuditReceipt`; `task_transition.cancelled` | `TaskTransitionCommand`; H5 | +| H7 generic config and UI | manifest config schema, legacy `Host.GetConfig`, `host.ui`, plugin registry | legacy `GetConfig(GetConfigRequest {})`; formats `agent-profile` and `textarea`; numeric `minimum`/`maximum`; `WorkspaceAgentChat`; `registerIntegrationSettings`; Integrations navigation | Existing JSON Schema config and registry DTOs | Typed validation/configuration-required states; revocable registration handles | `GetConfig` remains plugin-global v1; a later exact scoped config API must use a new DTO/version | No H6 capability for legacy config rendering; each new exact backing API uses its own `host.v2.*` capability | Config revision and owner lifecycle generation; no domain event | Plugin config/registry owners; H7 | + +No public Host name in this inventory depends on any product plugin being +installed. + +### H2d exact-head evidence + +`ChangeRequestEvidence` contains canonical provider ID and connection generation; +workspace; immutable repository ID plus canonical owner/name; base branch and base +commit; head branch and immutable head commit; immutable merge-ref identity when the +provider exposes one; draft, mergeability, conflict, and base-divergence states; +every exact check-run or job identity, status, conclusion, URL, and timestamps; causal +relationships from a check/job to its aggregate; review decision; requested and +completed reviewers; unresolved actionable thread count; provider rate budget and +reset; notification receipts; provider-action receipts; snapshot/resource version; +and provider read receipt. Aggregate green is never evidence that each causal job +passed, and an updated head invalidates evidence and action receipts from the old +head. + +### H1 result mapping + +H1-specific statuses are readback values, not additional `HostCommandResult` +states. When an H1 operation cannot proceed, the common result uses the parent +state and stable reason below. + +| H1 status | Parent state | Stable reason | Client behavior | +| --- | --- | --- | --- | +| `CONFIGURATION_REQUIRED` | `DENIED` | `HOST_CONFIGURATION_REQUIRED` | Park until the required Host configuration is present. | +| `BUSY` | `CONFLICT` | `CONVERSATION_BUSY` | Read current status and construct a new operation after it is available. | +| `FENCED` | `CONFLICT` | `EXECUTION_GENERATION_FENCED` | Stop the old generation and read the current generation before retry. | + +### H2e terminal resource provenance + +`TerminalResourceProvenance` contains canonical change and commit disposition; +pushed, contained, unpushed, and orphan history; owned worktree; local and remote +branches; active sessions, turns, processes, runtimes, data, and artifact consumers; +child and dependency closure; pending interactions and transitions; cleanup owner; +prior preview/action receipts; and its resource version. Done placement or merged +status never grants cleanup authority. + +Any later cleanup writer is a separate capability and command. It must be exact, +provenance-bound, policy-gated, dry-run capable, idempotent, auditable, and followed +by authoritative readback. Until that writer is separately approved, a plugin may +only report a Human cleanup action. + +### C1: installation authority and execution provenance + +The Host principal is `PluginInstallationPrincipal { installation_id, +workspace_id, capability_revision }`. It authorizes deterministic scheduler, +startup repair, reconciliation, event recovery, and outbox retry without requiring a +live managed conversation. + +An agent-originated exact write additionally binds the exact managed conversation, +session, and execution-generation fencing token. This provenance proves which +execution requested an operation; it never creates, delegates, or widens installation +authority. A missing, revoked, or stale approval revision is +`DENIED/STALE_CAPABILITY_REVISION` or `DENIED/CAPABILITY_REVOKED`; a stale +execution token is `CONFLICT/EXECUTION_GENERATION_FENCED`. + +### C2: atomic pending-transition safety + +Every writer that can wake, redirect, message, create work for, relate, or transition +a task accepts an exact `PendingTransitionGuard`. Its shared domain command +atomically revalidates the guard in the same transaction or serialized critical +section as the side effect. A separate list followed by send, move, ensure, or update +is not a safety proof. + +`CancelPendingTaskTransitionExact` may return a single-use continuation token bound +to the cancelled transition, target versions, installation, capability revision, +idempotency identity, and expiry. A following writer either consumes that token +atomically or revalidates the now-current pending-transition predicate itself. + +### C3: non-amplifying TaskDirectives + +A `TaskDirective` is bound to installation, capability revision, workspace, exact +task, exact capability class, canonical instruction/action digest, issue time, +expiry, revocation state, single-resolution state, optional task/workflow/session- +generation preconditions, stable idempotency identity, and Host audit ID. + +Worker admission intersects all of those fields with the current installation +approval and immutable Human-reserved policy. A directive cannot delegate itself, +widen through prose, authorize another operation, survive capability revocation, +resolve twice, or replay into a replacement execution generation. Failed admission +returns a typed denial or conflict and never partially dispatches. + +### C4: execution-generation fencing and durable dispatch + +`EnsureTaskRunExact` and `RecoverSessionExact` establish exactly one admissible +task/workstep execution generation and return its fencing token. Messages, +directives, prompt admission, completion, and transition signals for that run require +the token. Replacement terminally fences the previous generation before dispatch. + +The plugin persists an action intent/outbox record before any Host dispatch. It then +records the Host acknowledgment, `audit_id`, result, and authoritative readback. +Recovery retries the same idempotency identity. Failed-session unread queues are +diagnostic evidence, not durable work state. + +### C5: terminal provenance before cleanup + +H2e is the sole generic summary of terminal resources and consumers. Cleanup is never +inferred from a workflow column or change-request state. A missing provenance field, +active consumer, stale resource version, or unknown ownership causes a Human cleanup +action, not optimistic deletion. + +### C6: exact-head provider evidence + +H2d binds every provider observation and future action receipt to canonical +provider/repository/base/head and immutable head/merge-ref identity. Any future H3d +command is a separate capability with exact-head, current-state, rate-budget, and +idempotency preconditions. Merge is not a Host writer in this decision. + +### C7: permanent implementation decomposition + +H2 and H3 are documentation umbrellas only. Permanent implementation/task/PR units +are H2a through H2e and H3a through H3d. Each has one owner/worktree/PR unless source +evidence proves two shapes inseparable. No implementation unit may recreate a +Coordinator-specific core concept to avoid one of these generic boundaries. + +### Capability model and H6 decision + +Decision: **`H6_REQUIRED`**. + +Current source can enforce only a manifest declaration copied into a global installed +record. It cannot represent the principal or approval revision required above. + +| Source evidence | What exists | Why it is insufficient | +| --- | --- | --- | +| `apps/backend/internal/plugins/manifest/manifest.go` (`Capabilities`) | Manifest-owned `events`, `api_read`, `api_write`, and boolean declarations | A package declares its own ceiling; there is no Human-approved workspace set, revision, or revocation record. | +| `apps/backend/internal/plugins/store/store.go` (`Record`) | One filesystem installation record embeds the manifest and runtime status | The record is instance-global and has no workspace approval, approval history, actor, or immutable audit identity. | +| `apps/backend/internal/plugins/service_install.go` (`Service.Install`) | Admin-only install validates and saves the package manifest | Install does not persist a reviewed capability set distinct from the package declaration; an upgrade replaces the embedded manifest. | +| `apps/backend/internal/plugins/service.go` (`hostForPlugin`) | A Host instance copies `rec.Capabilities` at spawn/restart | Authorization knows plugin ID and declared capabilities only, not workspace, current approval revision, revocation, or execution provenance. | +| `apps/backend/internal/plugins/host.go` and `host_data.go` | Per-method declaration checks return `PermissionDenied` | The checks do not intersect an operator approval or produce a generic Host audit receipt. | +| `apps/backend/internal/task/models/models.go` (`PermissionResolutionAudit`) | Narrow audit for agent permission resolution | It is not a generic plugin command/audit ledger and cannot identify installation capability revisions. | + +The smallest extension is owned by the plugin system: + +| Contract | Required shape | +| --- | --- | +| `PluginCapabilityApproval` | One current row per `(installation_id, workspace_id)` with monotonic `revision`, manifest capability digest, approved capability IDs/classes, status `active\|revoked`, approving Human actor, issued/updated timestamps, and immutable Human-policy version. | +| `PluginCapabilityApprovalEvent` | Append-only grant, narrow, revoke, and upgrade-review events with `audit_id`, before/after revisions and digests, actor, reason, and timestamp. | +| Authorization | The effective set is `manifest declaration ∩ current workspace approval ∩ immutable Human policy`. The request revision must exactly equal the active revision. | +| Revocation | Revocation increments the revision, denies new exact calls immediately, fences outstanding directives/tokens, and returns `DENIED/CAPABILITY_REVOKED` to an old approval. | +| Upgrade | Equal or narrower declarations retain the approved set at a new recorded manifest digest and a new approval revision. Any widening remains unavailable until a Human approves a new revision; activation fails closed for required unavailable capabilities. | +| Compatibility | The legacy-v1 compatibility fence governs shipped RPCs. Every H1-H5 exact capability named here requires an explicit `host.v2.*` workspace approval revision; no v1 declaration implicitly gains it. | +| Audit | Every H1-H5 read receipt and write receipt records installation, workspace, exact approval revision, method/capability, request/idempotency digest, result, resource versions, and managed execution provenance when present. | + +No Coordinator principal, role, profile, grant, or audit type is created. + +`installation_id` is a Host-minted opaque UUID created at install and never +derived from plugin ID, package digest, workspace, or path. Its lifecycle is +part of the approval authority: + +| Lifecycle event | `installation_id` and approval behavior | +| --- | --- | +| Upgrade | Retains the same `installation_id`; records the new manifest digest and a new approval revision. Equal/narrower capability declarations preserve only the intersected active approvals. A widened declaration is inactive until a Human grants the widened `host.v2.*` set. | +| Rollback | Retains the same `installation_id`; records the rollback manifest digest and a new approval revision. It restores no prior approval implicitly: the active set is the current manifest intersection and current Human approval. | +| Uninstall | Immediately revokes every active approval, increments each revision, fences directives/tokens, and tombstones the `installation_id` plus approval-event history for audit. It cannot be reused for authorization. | +| Reinstall | Mints a fresh `installation_id` even for the same plugin ID/package/workspace. It starts with no H6 approval; a Human must create new workspace approvals. Tombstoned authority and old receipts remain historical only. | + +### Human-reserved and non-delegable matrix + +“Agent provenance” below means it is required when the request originated in a +managed agent execution. Installation approval is always required even when that +column says no. + +| Capability class | Installation approval may authorize | Agent provenance additionally required | Exact preconditions | Human-reserved | +| --- | --- | --- | --- | --- | +| Read-only projections | Yes | No | Workspace, capability revision, snapshot/resource version | No, except secret values and cross-workspace data | +| Managed conversation ensure/status/list/dispatch | Yes | Only for an agent-originated continuation | Conversation/config versions; idempotency; transition guard; generation token for continuation | Delete is reserved when unique/still-needed content is not proven disposable | +| Routine messages and task writes | Yes | Yes for agent-originated writes; no for deterministic backend reconciliation | Target/workflow/step versions, transition guard, idempotency, optional continuation token | No, unless the requested field/action enters another reserved row | +| Trusted directive issue/resolve | Yes, as a separately approved class | Yes for agent-originated issue/resolve | Directive digest/state, target versions, generation, expiry, transition guard | Cannot authorize any Human-reserved class | +| Relations and pending-transition cancellation | Yes | Yes for agent-originated writes | Endpoint/edge or transition versions, transition guard, idempotency | Destructive relation effects remain subject to dependency policy; no ToDeploy override | +| Task-run ensure and session recovery | Yes | Yes for agent-originated recovery; no for deterministic repair | Exact task/workstep/session/run versions, generation fencing, transition guard, outbox idempotency | No, but replacement must fence rather than rewrite history | +| Provider retry/rerun, draft-to-ready, reviewer notification | Not in H0 | N/A | Would require exact head, current state, idempotency, rate budget, audit, and readback | Deferred; no authority exists | +| Removal of unique or still-needed state | No | N/A | Exact provenance, dry-run receipt, Human confirmation, current versions | Yes | +| Security/trust-boundary changes | No | N/A | Human-authenticated exact change and policy version | Yes | +| Secret/credential disclosure or scope expansion | No | N/A | Human-authenticated secret scope and audit | Yes | +| Cross-workspace access | No | N/A | Separate Human approval per workspace; no wildcard delegation | Yes | +| Force-push, rebase, squash, amend, or history rewrite | No | N/A | No Host writer defined | Yes | +| Merge | No | N/A | No Host writer defined | Yes | +| Release | No | N/A | No Host writer defined | Yes | +| Deploy | No | N/A | No Host writer defined | Yes | + +### H3d provider-action decision + +H3d is explicitly deferred. Existing provider-neutral UI callbacks do not satisfy a +durable backend command boundary, and the built-in GitHub writer does not provide the +exact-head/idempotency/audit contract required here. + +| Candidate action | Decision | Provider-neutral target and exact-head/current-state preconditions | Idempotency and notification identity | Capability and result/rate-budget behavior | Audit receipt and authoritative readback | Source evidence and missing invariant | +| --- | --- | --- | --- | --- | --- | --- | +| Retry or rerun | Deferred | Must bind provider, repository, immutable change identity, exact head/merge ref, check/job identity, and current rerunnable conclusion. | Must use one key per exact head/check attempt; no notification identity applies. | A future `host.v2.write:change_actions` result must expose typed outcome and provider reset metadata; no capability is reserved now. | Must return `HostAuditReceipt`, provider action receipt, and exact post-action check readback. | `apps/backend/internal/github/client.go` exposes check reads but no provider-neutral check/job rerun command. Providers differ on workflow, pipeline, job, and check-suite identity. | +| Draft to ready | Deferred | Must bind provider, repository, immutable change identity, exact head/merge ref, and current draft state. | Must be idempotent per exact head and current draft state. | A future `host.v2.write:change_actions` result must expose typed outcome and provider reset metadata; no capability is reserved now. | Must return `HostAuditReceipt`, provider action receipt, and exact post-action draft readback. | `apps/packages/plugin-sdk/src/index.ts` exposes `supportsDraft` and draft creation only. There is no shared backend transition command or provider receipt. | +| Reviewer notification or re-request | Deferred | Must bind provider, repository, immutable change identity, exact head/merge ref, reviewer identity, and current review state. | Must use a notification-once-per-head key for each reviewer and request kind. | A future `host.v2.write:change_actions` result must expose typed outcome and provider reset metadata; no capability is reserved now. | Must return `HostAuditReceipt`, provider action receipt, and exact post-action reviewer readback. | `apps/backend/internal/github/service_pr.go` accepts repository/number/reviewer inputs without immutable-head binding, generic semantics, audit, or authoritative readback. | + +No capability string, RPC, or provider action is reserved for these candidates. A +later H3d ADR may include an action only after defining provider-neutral target +identity, immutable head/current state, idempotency, notification-once-per-head when +applicable, rate-budget behavior, audit receipt, and authoritative readback. Merge is +excluded. + +### Versioning and compatibility + +- `kandev.plugin.v1` and public SDK DTOs evolve additively. Removing/renaming fields, + changing field meaning, or reusing enum numbers requires a new protocol version. +- Unknown fields are ignored and preserved where the runtime permits. Unknown enum or + result values map to typed `UNSUPPORTED`; they never fall through to success. +- Capability IDs and result strings are permanent once shipped. A retired capability + identity is never reused for another meaning. +- The plugin manifest's existing `min_kandev_version` is the declaration consumed by + the Host. A plugin that needs this surface sets it to the first stable Kandev release + that implements every required capability. H0 does not guess that future version. +- Plugin upgrade follows the H6 revision rules above. A missing required capability, + stale approval revision, or older Host yields `UNSUPPORTED`/`DENIED`; the plugin + parks the action and reports a configuration/upgrade requirement. It must not fall + back to MCP, private REST, SQL, or shell. +- Host RPC and global MCP authorization is tested separately before parity: Host + authenticates `PluginInstallationPrincipal` and H6 approval/revision, while MCP + authenticates its ordinary user/session principal and policy. Only after each + adapter authorizes its own principal do parity tests invoke the same named domain + query/command and compare shared-domain preconditions, idempotency, conflicts, + mutation, audit/readback, and error vocabulary. A Host approval denial is never + expected to equal an MCP authorization verdict. +- Events are at-least-once hints with `event_schema_version`, `resource_version`, + `audit_id`, installation/workspace/capability revision, and immutable resource + identity. Unknown events may be ignored. Dropped, duplicated, delayed, or reordered + events are repaired by authoritative Host readback; event text is never authority. +- Rollout order is H6 approval/audit foundation, then required read contracts, then + exact writers, then the plugin adapters/UI. The plugin stays degraded and performs + no unsafe write when any required Host capability is absent. +- Exact-version integration I1 records accepted Host and plugin commit IDs, their + declared minimum/actual versions, capability revision, and compatibility-test + receipts. A moving branch or aggregate “latest” version is not evidence. + +### Migration and disposition ledger + +H0 does not close, message, move, rewrite, repurpose, or mutate any listed task or PR. + +| Evidence identity | Disposition and reusable evidence | Replacement owner / sequencing | +| --- | --- | --- | +| Task `9e67c426-1300-46ef-a00f-e5603791212d`; host PR #2793 at `afd2b699bfe9b6af9353ea01728582f61a7be2be` | Keep as monolithic read-only migration baseline. Extract conversation proto/SDK/service/test patterns from commits `e7ea57a1`, `e13ca6cd`, `76e44a74`, `9d2e0733`, `ad843976`, and `4baa37d8`; supersede Coordinator authority/storage/UI (`internal/coordinator`, workspace-agent principal/grant shapes). | H1 owns conversations; H2c owns relations; H6 owns generic approval/audit. Extract only after H0. | +| Plugin PR #1 at `5bfdbcf7d9608d1210453f95ebfc8f66c3179225`; tasks `52892e8e-dc44-4d38-80ab-14bb75f7b6bf`, `3ec598a8-b49d-4d5f-9bf3-52b0d611cd32` | Keep plugin/recovery baseline. Preserve `server/coordinator/{config,prompt,reports,state,tools}.go` and `ui/src/**` as plugin-owned evidence. Supersede `scheduler.go` dispatch bookkeeping with C4 intent/outbox and Host readback. | P1/P2 consume stable H1/H2 contracts; plugin recovery follows exact accepted Host head. | +| PR #2756 | Keep scheduler history (`internal/scheduler/cron`, session-wake repository/service/tests). Do not close until a linked plugin scheduler replacement exists. | Plugin scheduler/P1 after H0 and stable Host reads. | +| Task `c642d57a-5a24-48ca-8f85-57d31115eeb5`; PR #2909 | Retain generic automatic exact-turn repair and durable delivery evidence in lifecycle, orchestrator message queue, completion intent, and stale-session tests. | H4 extracts explicit `TaskRuns.EnsureExact` / `Sessions.RecoverExact`; it does not replace generic repair. | +| Task `9349b6e5-a167-4d88-af14-cb355015e3dd`; PR #2841 | Extract relation projection/service/wire tests (`host_task_relations.go`, `handoff_access.go`, related-task authorization tests). Supersede Coordinator-specific MCP authorization. | H2c, then H3c. | +| Task `ec384aac-cd4f-469c-8893-3aa8383da9d6`; PR #2974 | Retain task-inbox queue identity, bounded polling, transition, and trusted-principal evidence (`list_task_inbox`, messagequeue repository/service/tests). Do not reuse task-specific mutation authority. | H2b read projection only; H3a later owns exact writers. | +| Task `fa3fba49-2018-460b-a600-adae23b24cc8`; PR #3048 | Security-decision input only. Reuse adversarial provenance/audit test ideas; do not continue `internal/coordinator`, coordinator grants, UI, routes, tables, or MCP authority. | H6 generic plugin/workspace approval and Host audit extension. | +| Task `b2da5061-07a3-46e6-ab48-3881929ac9a5`; merged PR #3147 | Keep platform TTL/orphan invariant and row-local/race-safe pending-move sweep tests. Never recreate it in the plugin. | H2c observes; H5 composes with the retained platform invariant. | +| Task `7056a702-a3c3-4fe8-8535-c6b8d340ef6a`; PR #3155 | Retain exact compare-and-delete transaction history in `pending_move_exact_cancel.go` and its repository/service tests. No H0 mutation or disposition action. | H5 after H2c and this capability identity; C2 applies to every following writer. | +| Task `531a41cd-57ef-495a-8dfa-614d2a4d0d52` | Preserve the supplied Human-owned ToDeploy boundary as generic H3c design input only. Do not inspect or mutate the protected task. | H3c after H2c; no task-specific authority. | +| Task `afdb2ef3-06ca-4cd5-a074-c4e691679da9` | Preserve isolated QA baseline. H0 does not reuse moving version inputs. | Exact-version I1 replaces version inputs on accepted Host/plugin heads. | + +### Dependency and delivery graph + +1. H0 completes first. +2. H1, H2a through H2e, and H7 may then proceed independently where file + ownership permits. +3. H3a follows H2a/H2b; H3b follows H2a/H2c; H3c follows H2c; H3d follows H2d + only after a later action decision. +4. H4 follows H1, H2a, H2b, and H3a directive semantics. +5. H5 follows H2c and H0 capability identity. C2 must reach every affected + writer before plugin messaging/recovery is safe. +6. P1 follows H0 and stable H1/H2 state shapes. Each P2 adapter follows its Host + dependency; P3 follows H7 and stable P1 view models. +7. I1 runs only on exact accepted Host/plugin heads and the declared minimum + version. +8. Every node independently traverses Work, Review, QA, PR/CI, and semantic + Human-QA. None of those phases authorizes merge, deploy, release, or ToDeploy. + +## Consequences + +- The Coordinator remains independently releasable and core gains only reusable + plugin contracts. +- H6 is mandatory before any H1-H5 capability named here can authorize a write. +- The public contract is larger and intentionally exact: resource versions, + transition predicates, generation tokens, audit receipts, and readback add work to + every adapter but prevent split-brain automation. +- H3d has no implementation unit until provider-neutral semantics exist. +- Existing Host v1 behavior remains compatible for existing plugins, but it does not + implicitly grant the new surface. + +## Alternatives Considered + +### Continue the monolithic host PR + +Rejected because it makes product identity, grants, scheduling, and UI core concepts +and couples Coordinator delivery to a Kandev release. + +### Let the plugin call global MCP or private REST + +Rejected because ambient authority, consumer-specific authorization, and network +topology bypass installation capability revisions and cannot guarantee command +parity. + +### Treat the manifest as Human approval + +Rejected because a package authors its own manifest, upgrades can widen it, and the +current record has no workspace revision, revocation history, or generic audit. + +### Use list-then-write concurrency checks + +Rejected because another writer can change the pending transition, run generation, +or target resource between calls. + +### Approve provider actions now + +Rejected because current source does not prove stable provider-neutral identities or +exact-head/idempotency/audit semantics for any of the three candidates. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index bc4ee8918a..19cd609592 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -221,6 +221,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-30-empty-remote-bootstrap-publication | [Keep Empty Remote Bootstrap Local Until Publication](2026-08-30-empty-remote-bootstrap-publication.md) | accepted | backend, frontend, protocol, security | 2026-08-30 | | 2026-08-30-bound-untracked-dependency-enumeration | [Bound Untracked Dependency Enumeration](2026-08-30-bound-untracked-dependency-enumeration.md) | accepted | backend | 2026-08-30 | | 2026-08-30-e2e-browser-cache | [Cache host-runner E2E browser provisioning](2026-08-30-e2e-browser-cache.md) | accepted | infra, workflow | 2026-08-30 | +| 2026-08-31-generic-plugin-host-boundary | [Generic plugin Host boundary](2026-08-31-generic-plugin-host-boundary.md) | proposed | backend, frontend, protocol, security, workflow | 2026-08-31 | | 2026-08-30-environment-owned-git-status | [Persist Current Git Status with the Task Environment](2026-08-30-environment-owned-git-status.md) | accepted | backend, frontend, protocol | 2026-08-31 | | 2026-08-31-explicit-new-branch-session-recovery | [Require Explicit User Action Before Continuing a Session on a Replacement Branch](2026-08-31-explicit-new-branch-session-recovery.md) | accepted | backend, frontend, protocol | 2026-08-31 | | 2026-08-31-local-worktree-refresh-best-effort | [Local Worktree Refresh Is Best Effort](2026-08-31-local-worktree-refresh-best-effort.md) | accepted | backend, security, operations | 2026-08-31 | diff --git a/docs/specs/plugins/requirements/plugins.md b/docs/specs/plugins/requirements/plugins.md index 7f4035b4d1..5ddec76eff 100644 --- a/docs/specs/plugins/requirements/plugins.md +++ b/docs/specs/plugins/requirements/plugins.md @@ -27,6 +27,11 @@ Kandev keeps growing external integrations and surface-specific behavior directl - **AC-PLUGINS-PLUGINS-001.6:** The Settings > Plugins install dialog SHALL show the primary Install action as busy while an install is in flight, including an animated loading indicator and an installing label, while keeping the action disabled until the pipeline settles. - **AC-PLUGINS-PLUGINS-001.7:** Capability-based access control: a plugin can only call Host RPCs it declared in its manifest; undeclared capabilities are rejected with a gRPC `PermissionDenied` status. - **AC-PLUGINS-PLUGINS-001.8:** **Kandev owns the plugin process lifecycle**: it extracts the package, spawns the binary, performs the go-plugin handshake, health-checks it (`Ping`), and restarts it on crash or health-check failure. Operators no longer run or manage plugin processes themselves. The remote/self-hosted tier (`base_url` registration of an operator-run process kandev never spawns) is removed; see "Out of scope". +- **AC-PLUGINS-PLUGINS-001.9:** For a capability that needs workspace approval, Kandev shall authorize the request only when the manifest declaration, the current installation/workspace approval, and immutable Human-reserved policy all allow it. The Host shall enforce this intersection. A missing or revoked approval shall fail closed. +- **AC-PLUGINS-PLUGINS-001.10:** Each Host writer shall define and atomically revalidate the target versions and domain preconditions that apply to its command. A changed precondition shall return a typed conflict without a partial side effect. +- **AC-PLUGINS-PLUGINS-001.11:** A plugin shall access Kandev domain data and commands only through documented capability-gated Host contracts. Global MCP, private REST, direct database access, shell access, and prompt or comment text shall not confer plugin authority. +- **AC-PLUGINS-PLUGINS-001.12:** When a required Host capability or result state is unknown or unsupported, the plugin shall receive a typed unsupported result and shall not fall back to a broader integration path. +- **AC-PLUGINS-PLUGINS-001.13:** Kandev shall provide a connection-bound way for a plugin to discover supported capability families and approved workspace scope. The context shall describe authority but shall not grant authority. A missing, stale, or revoked context shall not grant authority. The Host shall enforce current approval on every request. ## System design diff --git a/docs/specs/plugins/system-design/plugins-02.md b/docs/specs/plugins/system-design/plugins-02.md index 2815d0798c..19d0b0aedd 100644 --- a/docs/specs/plugins/system-design/plugins-02.md +++ b/docs/specs/plugins/system-design/plugins-02.md @@ -341,6 +341,25 @@ undeclared capability returns gRPC `PermissionDenied` with message service instance is bound to the plugin's own ID at spawn time, the check evaluates directly against that plugin's installed manifest. +### Generic Host boundary + +Manifest declaration remains the package-authored maximum capability set. A +capability that needs workspace approval also uses the current +`(installation, workspace)` approval and immutable Human-reserved policy. The Host +enforces this intersection. The plugin cannot supply an installation identity or +grant itself authority. + +The generic service and capability-family boundaries, domain ownership, versioning, +and migration rules are recorded in +[ADR-2026-08-31-generic-plugin-host-boundary](../../../decisions/2026-08-31-generic-plugin-host-boundary.md). +Host RPC and global MCP adapters can use different request DTOs, but both call the +same named domain command or query. Neither adapter duplicates authorization, +ownership, idempotency, transition, lifecycle, relation, or audit invariants. + +The Host can provide a connection-bound capability context for discovery. The +context reports supported capability families and approved workspace scope. It does +not grant authority. The Host checks current approval for every request. + ## Filesystem sideloading & sync Besides the URL/upload install pipeline, an operator with shell access to the host diff --git a/scripts/generic-plugin-host-boundary.test.mjs b/scripts/generic-plugin-host-boundary.test.mjs new file mode 100644 index 0000000000..f1a4f05489 --- /dev/null +++ b/scripts/generic-plugin-host-boundary.test.mjs @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import test from "node:test"; + +const adrPath = "docs/decisions/2026-08-31-generic-plugin-host-boundary.md"; + +async function readADR() { + return fs.readFile(adrPath, "utf8"); +} + +function section(document, start, end) { + const startIndex = document.indexOf(start); + const endIndex = document.indexOf(end, startIndex + start.length); + assert.notEqual(startIndex, -1, `missing section ${start}`); + assert.notEqual(endIndex, -1, `missing section ${end}`); + return document.slice(startIndex, endIndex); +} + +function occurrenceCount(document, value) { + return document.split(value).length - 1; +} + +function splitMarkdownTableRow(line) { + const cells = []; + let cell = ""; + for (let index = 1; index < line.length - 1; index += 1) { + if (line[index] === "\\" && line[index + 1] === "|") { + cell += "|"; + index += 1; + } else if (line[index] === "|") { + cells.push(cell.trim()); + cell = ""; + } else { + cell += line[index]; + } + } + cells.push(cell.trim()); + return cells; +} + +function tableRows(document, start, end) { + return section(document, start, end) + .split("\n") + .filter((line) => line.startsWith("|")) + .map(splitMarkdownTableRow); +} + +test("freezes every H0 Host unit and correction", async () => { + const adr = await readADR(); + + for (const unit of [ + "H1", + "H2a", + "H2b", + "H2c", + "H2d", + "H2e", + "H3a", + "H3b", + "H3c", + "H3d", + "H4", + "H5", + "H7", + ]) { + assert.match( + adr, + new RegExp(`\\| ${unit}(?: | \\|)`), + `missing ${unit} inventory row`, + ); + } + for (let correction = 1; correction <= 7; correction += 1) { + assert.match( + adr, + new RegExp(`^### C${correction}:`, "m"), + `missing C${correction}`, + ); + } + + assert.match(adr, /Decision: \*\*`H6_REQUIRED`\*\*\./); + assert.match(adr, /H3d is explicitly deferred\./); + assert.match(adr, /### H6 capability context bootstrap/); + assert.match(adr, /`GetCapabilityContext`/); + assert.match(adr, /capability-context-changed/); + assert.match(adr, /### H1 result mapping/); + assert.doesNotMatch(adr, /SetTaskFlagsExact|flag requests/); + + for (const contract of [ + "`PluginCapabilityApproval`", + "`PluginCapabilityApprovalEvent`", + "manifest declaration ∩ current workspace approval ∩ immutable Human policy", + ]) { + assert.match(adr, new RegExp(contract), `missing H6 contract ${contract}`); + } + for (const candidate of [ + "Retry or rerun", + "Draft to ready", + "Reviewer notification or re-request", + ]) { + assert.match( + adr, + new RegExp(`\\| ${candidate} \\| Deferred \\|`), + `missing H3d candidate ${candidate}`, + ); + } +}); + +test("keeps the public Host inventory generic and merge-free", async () => { + const adr = await readADR(); + const inventory = section( + adr, + "### Public Host surface inventory", + "### H2d exact-head evidence", + ); + + assert.doesNotMatch(inventory, /coordinator/i); + const inventoryRows = inventory + .split("\n") + .filter((line) => line.startsWith("| H")); + const methodAndCapabilityColumns = inventoryRows + .map((line) => { + const cells = line.split("|"); + return `${cells[3]}|${cells[7]}`; + }) + .join("\n"); + + assert.doesNotMatch( + methodAndCapabilityColumns, + /\bmerge(?:[A-Z][A-Za-z0-9_]*)?\b/i, + ); + assert.doesNotMatch(methodAndCapabilityColumns, /api_write:merge/i); + assert.doesNotMatch(inventory, /api_(?:read|write):[^`\s|]*coordinator/i); + assert.match(inventory, /No Host writer is approved in H0/); +}); + +test("preserves literal pipes inside decision-table cells", async () => { + const adr = await readADR(); + const inventoryRows = tableRows( + adr, + "### Public Host surface inventory", + "### H2d exact-head evidence", + ); + const capabilityRows = tableRows( + adr, + "The smallest extension is owned by the plugin system:", + "No Coordinator principal, role, profile, grant, or audit type is created.", + ); + + assert.ok(inventoryRows.length > 1, "missing Host inventory table"); + assert.ok(capabilityRows.length > 1, "missing capability contract table"); + assert.ok( + inventoryRows.every((row) => row.length === 9), + "Host inventory row was split by a literal pipe", + ); + assert.ok( + capabilityRows.every((row) => row.length === 2), + "capability contract row was split by a literal pipe", + ); + assert.match(adr, /task_directive\.issued\\\|resolved\\\|revoked/); + assert.match(adr, /task_relation\.added\\\|removed/); + assert.match(adr, /status `active\\\|revoked`/); +}); + +test("freezes the complete deferred H3d decision table", async () => { + const adr = await readADR(); + const rows = tableRows( + adr, + "### H3d provider-action decision", + "### Versioning and compatibility", + ); + const headers = rows[0].join(" "); + + assert.equal(rows[0].length, 7, "H3d table must expose all decision fields"); + assert.ok( + rows.slice(1).every((row) => row.length === 7), + "H3d row has malformed columns", + ); + for (const field of [ + "exact-head/current-state", + "Idempotency", + "Capability", + "Audit receipt", + "readback", + ]) { + assert.match(headers, new RegExp(field, "i"), `H3d table missing ${field}`); + } + assert.doesNotMatch( + adr.slice( + adr.indexOf("### H3d provider-action decision"), + adr.indexOf("### Versioning and compatibility"), + ), + /\| Merge \|/i, + ); +}); + +test("fences legacy v1 calls from H6 exact authority", async () => { + const adr = await readADR(); + const legacy = section( + adr, + "### Legacy v1 compatibility fence", + "### Public Host surface inventory", + ); + + for (const required of [ + "GetConfig(GetConfigRequest {})", + "GetConfigRequest {})` | Ungated, plugin-global operator configuration", + "ListTasks", + "ListSessions", + "CreateTask", + "api_read:", + "api_write:", + "No synthetic legacy revision exists.", + "cannot authorize any new exact capability or bypass H6/C1/C2 safeguards", + ]) { + assert.ok(legacy.includes(required), `missing legacy fence: ${required}`); + } + assert.match( + adr, + /Every \*\*new exact\*\* Host request introduced by this ADR carries/, + ); + assert.match(adr, /host\.v2\.read:tasks/); + assert.match(adr, /host\.v2\.write:tasks/); + assert.doesNotMatch(legacy, /synthetic legacy revision only/i); +}); + +test("defines approval lifetime, result parents, and principal-correct parity", async () => { + const adr = await readADR(); + + for (const lifecycle of ["Upgrade", "Rollback", "Uninstall", "Reinstall"]) { + assert.match( + adr, + new RegExp(`\\| ${lifecycle} \\|`), + `missing installation lifecycle ${lifecycle}`, + ); + } + assert.match(adr, /Mints a fresh `installation_id`/); + assert.match(adr, /tombstones the `installation_id`/); + assert.match( + adr, + /\| `DENIED` \| `STALE_CAPABILITY_REVISION`, `CAPABILITY_REVOKED`, `HUMAN_RESERVED`/, + ); + assert.match( + adr, + /\| `CONFLICT` \| `STALE_RESOURCE_VERSION`, `PENDING_TRANSITION_CONFLICT`, `EXECUTION_GENERATION_FENCED`/, + ); + assert.match( + adr, + /Host RPC and global MCP authorization is tested separately before parity/, + ); + assert.match( + adr.replace(/\s+/g, " "), + /A Host approval denial is never expected to equal an MCP authorization verdict/, + ); +}); + +test("assigns pending-transition reads only to H2c", async () => { + const adr = await readADR(); + const inventory = section( + adr, + "### Public Host surface inventory", + "### H2d exact-head evidence", + ); + const h5Row = inventory.split("\n").find((line) => line.startsWith("| H5 ")); + + assert.ok(h5Row, "missing H5 inventory row"); + assert.match(h5Row, /CancelPendingTaskTransitionExact/); + assert.doesNotMatch( + h5Row, + /ListPendingTaskTransitions|TaskTransitionQuery|api_read:/, + ); +}); + +test("pins immutable migration evidence and forbidden integration paths", async () => { + const adr = await readADR(); + const normalized = adr.replace(/\s+/g, " "); + + assert.equal( + occurrenceCount(adr, "afd2b699bfe9b6af9353ea01728582f61a7be2be"), + 2, + "host baseline head must remain consistent in context and migration ledger", + ); + assert.equal( + occurrenceCount(adr, "5bfdbcf7d9608d1210453f95ebfc8f66c3179225"), + 2, + "plugin baseline head must remain consistent in context and migration ledger", + ); + for (const forbidden of [ + "ambient/global Kandev MCP", + "private or undocumented REST", + "direct database access", + "shelling into Kandev", + ]) { + assert.match(normalized, new RegExp(forbidden.replace("/", "\\/"))); + } +});