# A2A task plane

Channels adopts [A2A v1](https://github.com/a2aproject/A2A) as its common
task protocol ([RFC #35](https://github.com/ai-outfitter/channels/issues/35)).
This module is the first contract commit. It defines the HTTP+JSON server
binding and the durable task store. It also defines the mandatory idempotency
rule, the Outfitter task extension, and the resident-agent bridge. Native
adapters keep their own event intake and result delivery. The
[source conformance matrix](./a2a-source-conformance.md) defines their
source-specific contracts.

## Pinned protocol source

The data model derives from the authoritative Protocol Buffer model at A2A
release `v1.0.1`, commit `3303592588e388e62e0f69f701af531d2f4e3991`, vendored
with provenance at [`extensions/vendor/a2a/`](../extensions/vendor/a2a/README.md).
Wire names follow ProtoJSON. An upgrade is an explicit compatibility change.

Two upstream inconsistencies, resolved as follows:

- **Subscribe method.** The v1.0.1 prose maps `SubscribeToTask` to `POST`;
  the authoritative proto annotation says `GET`, and upstream
  [a2aproject/A2A#2068](https://github.com/a2aproject/A2A/pull/2068) corrects
  the prose to `GET`. This implementation serves `GET /tasks/{id}:subscribe`.
  `GET` is also the only method a browser-native `EventSource` can issue.
- **Error format.** §6.4 shows `application/problem+json` for the version
  error while §11.6 defines the normative `google.rpc.Status` JSON error
  model. This implementation uses §11.6 uniformly: every error body carries
  `code`, `message`, and a `google.rpc.ErrorInfo` detail with an A2A `reason`
  and `domain: "a2a-protocol.org"`.

## Surface

| Route | Method | Purpose |
| --- | --- | --- |
| `/.well-known/agent-card.json` | GET | Public Agent Card: streaming on, push notifications off, `outfitter-task/v1` and `elicitation/v1` extensions declared |
| `/message:send` | POST | Send a message; blocks until the task settles unless `configuration.returnImmediately` |
| `/message:stream` | POST | Send and stream `task` / `statusUpdate` / `artifactUpdate` frames over SSE |
| `/tasks` | GET | List the caller's tasks, filtered by `contextId`, `status`, `statusTimestampAfter` |
| `/tasks/{id}` | GET | Read one task's durable state |
| `/tasks/{id}:subscribe` | GET | SSE stream of updates for a non-terminal task |
| `/tasks/{id}:cancel` | POST | Cancel a non-terminal task |
| `/tasks/{task_id}/pushNotificationConfigs*` | * | Explicit `501` — the Card declares `pushNotifications: false` |

Version negotiation: an `A2A-Version` header other than `1.0` fails
explicitly with `VERSION_NOT_SUPPORTED`. Authentication is bearer-token; each
token maps to a principal, and every task and dedupe record is scoped to it.

Extension use is negotiated per request. A client lists the supported extension
URIs it wants to activate in `A2A-Extensions`; the server echoes the supported
subset in the response header and carries only those URIs on contributed
messages. In particular, a caller must activate `elicitation/v1` before the
resident emits its typed data part. Otherwise `a2a_require_input` preserves the
plain-text question and omits the extension payload.

## Task and conversation semantics

This section states the implemented contract for the task-plane milestone.

Message versus Task is the executor's decision. Work mints a Task. A simple
interaction returns a direct A2A Message and no Task ever exists. For an
activation that mints a Task, the rules below apply.

Each provider event that is not a continuation creates exactly one new
Task. An event is a continuation only through the two methods below. Separate
events in one conversation create separate Tasks in the same A2A
`contextId`.

Channels stores this atomic mapping:

```text
(principal, source, conversationKey) -> contextId
```

The `conversationKey` is the source's Conversation fields from the
[source conformance matrix](./a2a-source-conformance.md).

Channels keeps this mapping for 30 days after the last provider event in
the conversation, and never removes it while a non-terminal Task references
its `contextId`. Concurrent
first messages use one atomic upsert. They receive the same `contextId`.

The trusted task-plane interface creates a new Task in an existing context
(`createTask(principal, contextId)`). A conversation key
never selects a Task. A thread identifier never continues a Task.

A provider reply continues a Task only through one of these methods:

- A verified reply anchor selects the Task.
- An authorized client supplies an explicit `taskId`.

An explicit `taskId` is authorized under the same convention as `contextId`:
it attaches only when the requesting principal already owns the Task. An
explicit `taskId` arrives only over the trusted task-plane interface or the
authenticated A2A binding. Channels never parses a `taskId` out of a provider
message body — sender-controlled values remain untrusted.

A verified reply anchor requires all these conditions:

1. The Task has the `INPUT_REQUIRED` or `AUTH_REQUIRED` state.
2. Channels sent a provider response for that Task.
3. Channels stored the provider response ID as a reply anchor.
4. The new event directly replies to that anchor.
5. The event's authenticated intake principal matches the anchor's
   principal. The anchor-key scope enforces this condition.
6. The Task records an unanswered input request.

If any condition fails, the event creates a new Task in the conversation's
context — the same disposition as a normal thread message. Anchor failure is
never a rejection; only an unauthorized explicit `taskId` fails explicitly.

Channels stores this reply-anchor mapping:

```text
(principal, source, providerResponseId) -> taskId
```

A source supports reply anchors only where the provider exposes a per-message
reply relation to the response Channels sent. A thread-root identifier does
not satisfy condition 4. The
[source conformance matrix](./a2a-source-conformance.md) records per-source
support in its Continuation column. Only an authorized explicit `taskId`
continues a Task from a source without reply-anchor support.

Channels accepts an item when its Task, wake, and evidence record are
durably committed. A source may advance its checkpoint or acknowledge the
provider only after acceptance.

Task creation grants no authority. An inbound message does not select an
agent, a tool set, or a workflow topology.

## Contract decisions

- **Idempotency is mandatory and stronger than the A2A minimum.** Scope:
  `(authenticated principal, messageId)`. Stored outcome: the full prior
  result — the created Task (a duplicate returns that Task) or the direct
  Message verbatim. For work-producing A2A intake, the fsynced activation
  journal claim is the dedupe authority and supplies the prior Task directly;
  Task and context IDs minted by the server are excluded from the activation
  digest. A persistence failure returns an error and records no successful
  outcome. Retention: 30 days, never shorter than the referenced task's life.
  Once that Task and its claim have been pruned, the same identity is accepted
  as new work instead of returning a nonexistent Task. A duplicate `messageId`
  with a different payload is an
  explicit `409 DUPLICATE_MESSAGE_ID`, never a silent replay. For native
  intake, the source's Event identity fields from the conformance matrix,
  scoped to the intake principal, serve as the `messageId` for this rule.
- **Recovery uses durable state.** Streams are ephemeral; the durable Task —
  status, history, artifacts — is the record. A reconnecting client reads
  the Task, then opens a new subscription; nothing replays.
- **Wake authority begins at delivery.** The queue grants authority, transitions
  the Task to `WORKING`, and records `WOKEN` immediately before prompting that
  Task's Pi session. Authority lasts through that session's turn. The queue
  clears it before offering another Task; an interrupted Task waits for a newly
  accepted continuation rather than being offered again immediately.
- **Wake recovery follows durable Task state.** `WOKEN` records that Pi was
  offered a turn, not that the turn completed. On startup, only the newest
  accepted activation for each non-terminal Task is eligible for the new
  runtime, including one already marked `WOKEN`. An unanswered `INPUT_REQUIRED`
  or `AUTH_REQUIRED` Task is not offered again until a newer continuation is
  accepted. Terminal claims are filtered before queue admission and do not
  count toward the bound. A newly accepted continuation starts normally. A
  successful delivery is re-offered at most five consecutive times while its
  Task remains unsettled. Reaching that durable cap records `WAKE_FAILED` and
  unhealthy evidence; startup does not resurrect the activation. A new provider
  event or explicit continuation creates a new activation and may enqueue it.
  A Task-session turn failure retries three times, records unhealthy evidence,
  closes the live session, and leaves the activation replayable after restart.
  A legacy wake transport failure records `WAKE_FAILED` after the same bounded
  retries. Transient store or journal failures during pumping schedule a
  macrotask retry with capped exponential backoff.
- **Wake admission is bounded.** At most 128 wakes wait behind the offered or
  active turn. Overflow is logged and recorded as durable `WAKE_FAILED`
  evidence, so a duplicate-prone source cannot grow the resident queue without
  limit or fail silently.
- **Operational records are compacted at startup.** After incomplete claims
  replay, the journal keeps pending claims, records for retained Tasks, and the
  30-day window. Evidence, reply anchors, outbound deliveries, and contexts use
  the same window while retaining records required by a live Task; an outbound
  delivery still in `sending` is never pruned.
- **Outbound delivery is serialized per delivery ID.** Concurrent identical
  responses share one durable state transition and one provider mutation. A
  persisted `lookup` delivery without its exact reconciler fails closed as
  ambiguous instead of sending blindly.
- **Server-scoped identity.** The server mints every task id. Equal ids from
  two servers cannot collide because identity is the locator (interface URL
  + binding + version + tenant + taskId), carried by the `outfitter-task/v1`
  extension ([schema](./extensions/outfitter-task.v1.schema.json)). A
  client-supplied `contextId` attaches only when the same principal already
  owns it; a foreign or unknown `contextId` gets a fresh context instead of
  joining local work.
- **Interrupted is not terminal.** `INPUT_REQUIRED` and `AUTH_REQUIRED`
  settle a blocking send and remain subscribable and continuable; only
  completed/failed/canceled/rejected are final.
- **A blocking send is bounded.** At `blockingTimeoutMs` (default 60 s) the
  send returns the task's current snapshot rather than holding the socket;
  the client continues by polling or subscribing.
- **Streaming shows what happens after you connect.** An executor that
  settles the task before returning (a fast inline executor) emits its
  updates before the `message:stream` forwarder attaches, so the client
  receives only the final `{task}` frame — the durable state carries
  everything regardless. The resident-agent bridge returns the submitted Task
  after its activation claim is durable; later status and artifact updates
  stream live.

## Resident-agent bridge

`extensions/a2a-extension.ts` hosts the optional server inside a resident Pi
profile. The listener is inert unless `A2A_SERVER=1`; the task tools are
registered whenever the task plane runs, including native-only deployments.
The composed runtime always injects its already-open shared store, so
`A2A_STORE_PATH` is no longer required. Configuration is
`A2A_CREDENTIALS_PATH` (required, `{"credentials": [{"token", "principal"}]}`),
`A2A_HOST`/`A2A_PORT` (default loopback:8788), and `A2A_PUBLIC_URL` /
`A2A_AGENT_NAME` / `A2A_AGENT_DESCRIPTION` / `A2A_AGENT_VERSION` for the Card.
`A2A_WORKFLOW_MANIFEST` points to the exported `workflow-composition.json`, and
`A2A_WORKFLOW` selects its workflow ID when the manifest contains more than one.

An inbound work message first enters the trusted task-plane sink. Acceptance
writes the journal claim, creates or continues the Task, appends the authorized
history, projects evidence, and queues a **body-free wake** before returning the
Task. Explicit continuation is authorized before its caller message is
persisted. The wake queue changes the Task to `WORKING`, grants it as the turn's
sole authority, and creates or reopens the durable Pi session derived from its
Task ID. The coordinator owns sources and stores but performs no inference.
Task sessions run on the resident's current model and thinking level, inherited
from the main session at creation.
Task sessions receive the resident's non-Channels extensions plus the shared
Task-authorized channel tools; they do not start another listener or source
runtime. The agent then drives the task with four tools:

- `a2a_read_task` — read the task's history inside untrusted-content markers.
- `a2a_complete_task` — record each entry in its optional `outputs` parameter,
  then the response, as artifacts and complete; or reject with a reason.
- `a2a_record_output` — record one declared workflow output while the Task is
  still `WORKING`.
- `a2a_require_input` — pause the task on the caller with a question; the
  task enters `INPUT_REQUIRED`. An optional MCP-compatible `requestedSchema`
  is emitted as an `elicitation/v1` data part, while the question remains as a
  text fallback. The answer arrives as an authorized
  explicit `taskId` follow-up; the runtime commits add verified reply
  anchors, and make this tool fail for a Task whose source declares no
  continuation method in the conformance matrix — the executor completes or
  rejects instead, so no Task strands in `INPUT_REQUIRED`. The answer causes
  a new wake for that Task and reopens its durable Pi session after the paused
  turn's live resources close. This tool is the protocol-native structured-question
  surface ([#27](https://github.com/ai-outfitter/channels/issues/27)).

### Output artifacts

Each recorded output becomes one artifact named for the declared output. Its
metadata under `outfitter-task/v1` carries `output`, the resolved Outfitter
output label as `type`, and the concrete object observed by the engine as
`value`. An output MAY be streamed while the Task is `WORKING` as an
`artifactUpdate`, allowing a consumer to act before completion.

The legal output names come from the workflow selected from
`A2A_WORKFLOW_MANIFEST`, with `A2A_WORKFLOW` selecting among multiple workflows.
Manifest output names and types must match `^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`; a manifest violating this pattern fails to load and is logged as `a2a_workflow_manifest_load_failed`.
The bridge rejects undeclared names. It validates only that `value` is a JSON
object; value schemas and deeper validation are deferred to
[ai-outfitter/outfitter#380](https://github.com/ai-outfitter/outfitter/issues/380).
The produced object's state (for example draft, reviewed, or merged) belongs to
the forge: the consumer reads it from the forge, and the agent never asserts it
in the artifact value.

### Upgrade from the 1.7 standalone A2A store

This release does not automatically merge the legacy `A2A_STORE_PATH` document
into the shared task-plane store. Automatic merging could combine principal,
message-dedupe, and task identities without enough provenance to resolve a
collision safely. Before upgrading an A2A-only deployment, complete or export
any active tasks in the legacy store and retain that file as the audit archive.
New and continued work after the upgrade uses
`${XDG_DATA_HOME:-$HOME/.local/share}/outfitter/channels/task-plane/tasks.json`
unless `CHANNELS_TASK_STORE_PATH` selects another task-plane root.

All four tools require the exact Task to be the active turn authority. A task
ID from another queued or completed Task, or a Task with no activation claim,
is rejected. There is no claim-free resident-owner path. For a native Task whose
source declares no continuation method, `a2a_require_input` fails and the
executor must complete or reject the Task.

The default `${XDG_DATA_HOME:-$HOME/.local/share}/outfitter/channels/task-plane`
store has no cross-process journal lock.
Only one Channels process may open a given store root at a time. Concurrent Pi
sessions must set distinct `CHANNELS_TASK_STORE_PATH` roots. A cross-process
lock is required before this single-process constraint can be removed.

## Verification gates

`tests/a2a-task-plane.test.ts` proves, by name, the gates from the RFC's
conformance note: equal task IDs from two servers do not collide; a foreign
`contextId` does not join local work; a reconnect can miss transient messages
without losing critical state; a simple interaction returns a direct Message
and a duplicate direct Message returns its prior direct result; an unsupported
A2A version fails explicitly; and subscribe uses `GET`, streams updates, and
rejects terminal Tasks.

The existing continuation test (`tests/a2a-task-plane.test.ts`) already
proves the authorized explicit `taskId` method, which this contract keeps —
the runtime commits keep that test. They add the new gates: two messages in
one provider thread create two Tasks in one context; concurrent first
messages resolve one `contextId` through the atomic upsert; the trusted
interface creates a new Task in an existing context; a verified direct reply
continues one waiting Task, including from the `AUTH_REQUIRED` state; a
redelivered duplicate provider event returns the prior Task; a reply anchor
from a principal that cannot access the Task creates a new Task instead; a
later thread message creates a new Task; an unauthorized explicit `taskId` fails; and a `taskId`
embedded in a provider message body neither selects nor continues a Task.
