Repository navigation
Add provider-neutral Issue read and upsert workflow effects #296
Description
Story
As a workflow author, I want one provider-neutral <Issue> component that can
read an issue by URL or upsert an issue in the tracker selected by lexical
context, so the same document works with GitHub, Atlassian, or another installed
provider without embedding provider transport in the document.
Architecture amendment
This contract supersedes the earlier deferred-finding contract, the upsert-only
amendment, and PR #516's current component and test contract.
Issue handling is one ordinary contextual API with two operations: read and
upsert. Providers compose with IssueApi.around(...), handle requests they
own, and delegate the rest. There is no provider registry, central URL resolver,
routing protocol, phase API, answer protocol, or private terminal.
Read and upsert are different durable operations behind one component:
<Issue url="..." as="issue" />reads one existing issue.<Issue title="...">description</Issue>upserts one issue in the nearest
<IssueTracker>.
The syntax selects the operation before routing. Mixed or incomplete forms are
refused before IssueApi is invoked.
Public component
Read form
<Issue url={finding.issueUrl} as="issue" />
{issue.title}: {issue.description}The read form is self-closing. It requires url, permits an optional
provider discriminator for a self-hosted or otherwise non-standard URL, and
accepts no title, tags, assignee, or content.
It binds provider-neutral current issue fields:
interface IssueDetails {
readonly url: string;
readonly title: string;
readonly description: string;
readonly tags: readonly string[];
readonly assignee: string | null;
}The first contract deliberately contains only the fields shared with upsert.
Provider-native IDs, numbers, workflow states, payloads, and reconciliation
details are not public fields.
Upsert form
<IssueTracker url={props.tracker}>
<Issue title="Retry the publish step" tags={["reliability"]} as="issue">
The publish step failed twice in a row on 503.
</Issue>
</IssueTracker>
Filed {issue.url}.The upsert form requires title and paired content. The rendered content is the
issue description, preserved verbatim after expansion. It permits optional
tags and assignee, has no description prop, and refuses url and
provider props.
title and the rendered description are non-empty. tags defaults to an empty
array and is normalized as a code-point-sorted set. assignee defaults to no
assignee and remains an opaque provider account identifier.
Upsert renders nothing and binds exactly:
interface IssueReference {
readonly url: string;
}Deferral classification, approval, PullRequest provenance, rationale,
milestones, projects, comments, closing, and provider-specific issue fields are
workflow or provider policy, not props on this primitive.
Lexical tracker
Upsert requires the nearest lexical tracker:
interface IssueTracker {
readonly url: string;
readonly provider?: string;
}The tracker URL names the container in which issues are created. Its optional
provider is the explicit discriminator. A nested tracker replaces the whole
value for its descendants and restores the outer value afterwards.
Read does not require an <IssueTracker> because the issue URL identifies the
object being read. Its optional provider prop serves the same explicit
discrimination purpose for self-hosted reads.
Tracker context is shared composition data, not authority. It contains no
credential, endpoint, transport, or permission. The selected provider validates
the requested URL against a host-owned target ceiling held beside its
credentials before any HTTP request.
IssueApi
interface IssueInput {
readonly title: string;
readonly description: string;
readonly tags: readonly string[];
readonly assignee: string | null;
}
interface IssueReadOptions {
readonly provider?: string;
}
interface IssueUpsertOptions {
readonly url: string;
readonly provider?: string;
readonly idempotencyKey: string;
}
interface IssueApi {
read(url: string, options: IssueReadOptions): Operation<IssueDetails>;
upsert(issue: IssueInput, options: IssueUpsertOptions): Operation<IssueReference>;
}Both base methods throw NoIssueProvider after every installed middleware
delegates. The error names the operation, URL, and explicit discriminator when
present.
Provider middleware applies the same routing rule independently to each method:
- With an explicit discriminator, a non-matching provider delegates unchanged.
The matching provider handles or refuses; it never falls through after
matching. - Without a discriminator, a provider handles URLs it recognizes and delegates
all others unchanged. - Middleware order is precedence. A lexical override handles the methods and
URLs it overrides and delegates everything else. - Providers never catch
NoIssueProvideras a fallback protocol.
IssueApi carries no credential, endpoint, transport, provider-native ID, raw
payload, reconciliation phase, or journal capability.
Durability
Both operations are journaled as issue_effect with an explicit operation
discriminator in the normalized request.
Read
The read request contains the canonical issue URL and optional provider. Its
result is the normalized IssueDetails. Replay returns that retained snapshot
without invoking IssueApi or making HTTP requests. A separate authored read at
a different durable position is a separate observation.
Upsert
The upsert request contains the canonical tracker URL, optional provider, and
normalized IssueInput. The durable envelope derives the idempotency key from
the workflow run, expansion position, operation, and target URL. Replay returns
the retained { url } without invoking IssueApi.
A live retry after external success but before local publication calls the same
provider with the same idempotency key. The provider observes and adopts the
existing issue rather than creating a duplicate. Unavailability, ambiguity,
conflict, and a closed issue never become absence.
The complete durable name covers the operation and every normalized request
member. A read cannot consume an upsert result, and one URL or issue request
cannot consume another's result.
Filtered history retains only normalized requests and normalized public
results. It retains no credential, endpoint, raw payload, provider-native ID,
origin marker, or host-local path.
GitHub provider
The production GitHub provider implements both API methods.
Read parses a canonical GitHub issue URL, checks the host ceiling, and sends an
authenticated GET /repos/{owner}/{repo}/issues/{number}. It refuses a pull
request returned through GitHub's Issues endpoint and normalizes the issue into
IssueDetails.
Upsert checks the ceiling, lists repository issues to find the idempotency
marker, then adopts, sends POST /repos/{owner}/{repo}/issues, or sends
PATCH /repos/{owner}/{repo}/issues/{number} followed by one confirming read.
The default Deno transport reads GH_TOKEN, then GITHUB_TOKEN, and performs
these requests with the platform's asynchronous fetch. Cancellation aborts
the request.
The actual xmd workflow Deno host must install the GitHub middleware from
host-owned configuration carrying its authorized target ceiling. Installing
the adapter only in test code or exposing an unused withWorkflowWorkspace
option does not deliver the production component. The tracker URL is never used
as its own ceiling, and absence of host configuration remains fail-closed.
Markdown evidence
Public behavior is tested in checked-in *.test.md documents using <Test> and
the assertion components. The test setup is part of the document a reviewer
reads.
Do not use an omnibus IssueFixture harness that hides state reset, provider
installation, ceilings, credentials, faults, journals, remote issues, and
counters behind one setup system. Split test support by responsibility and make
each dependency explicit where the scenario uses it.
A scenario visibly declares, as applicable:
- the workflow attempt or retained journal it runs under;
- each installed provider and its discriminator;
- the provider's authorized ceiling;
- the local HTTP endpoint and test credential;
- the HTTP responses or remote issues present before execution;
- injected transport or interruption behavior; and
- the requests and retained events asserted afterwards.
Use narrow declarative test components whose names state what they provide, for
example a workflow-run boundary, a GitHub provider boundary, a local GitHub HTTP
server, an expected response, a seeded remote issue, and collected requests.
Their TypeScript implementations may provide host capabilities, but they do not
combine unrelated setup or make consequential defaults invisible.
At least one Markdown integration scenario runs the shipped GitHub provider
through denoGitHubAccess(localServerUrl) against a loopback HTTP server. It
must visibly declare the server responses and assert the received method, path,
authorization header, and JSON body. This exercises the real asynchronous
fetch transport without sending requests to github.com or requiring a real
credential.
The Markdown scenarios cover:
- read syntax, normalized result, routing, ceiling, HTTP GET, and replay;
- upsert syntax with rendered content as description;
- exact mode validation and exact return shapes;
- URL matching with GitHub-shaped and Atlassian-shaped providers installed
together; - explicit provider routing for non-standard read and upsert URLs;
- refusal finality and
NoIssueProvider; - nested tracker and lexical
IssueApioverrides; - GitHub list/create/update request sequences;
- interrupted-create recovery and stable idempotency key; and
- filtered history containing no credential, endpoint, payload, marker, or host
path.
Direct TypeScript tests remain appropriate for pure parsers, canonicalization,
pagination parsing, serialization, and malformed provider payloads. They do not
duplicate component, routing, HTTP integration, replay, or recovery scenarios.
Acceptance
- Self-closing
<Issue url="..." as="issue" />durably reads and binds exactly
normalizedIssueDetails. - Paired
<Issue title="...">description</Issue>uses its rendered content as
the upsert description, renders nothing, and binds exactly{ url }. - Read and upsert forms reject mixed, missing, or inapplicable props before
IssueApiis called. IssueApiexposes exactlyread(url, options)and
upsert(issue, options); providers compose throughIssueApi.around(...).- URL matching, explicit discrimination, refusal finality, lexical precedence,
andNoIssueProviderapply to both methods. - Read replay returns its retained snapshot without provider or HTTP access.
- Upsert replay returns retained
{ url }; an interrupted create retries with
the same key and creates no duplicate. - The GitHub provider performs the specified GET/list/POST/PATCH requests via
its real asynchronous transport and validates the host ceiling first. - The production Deno workflow host can install configured GitHub middleware;
no configuration remains fail-closed. - Markdown tests visibly declare provider, ceiling, transport, remote state,
faults, attempts, and request assertions. No omnibus hidden fixture remains. - Credentials, endpoints, payloads, provider IDs, markers, and host paths do not
enter component props, lexical tracker context, public API results, or
filtered history.
Dependencies
- Add shared external Git-host effect reconciliation #297 supplies reconciliation behavior; its Git-host public boundary owns Push
and PullRequest only. - Add workflow inspection, cancellation, suspension, and deletion #367 and Deliver answers to suspended workflows for later resumption #300 supply durable suspension when Issue middleware applies an
approval policy. - End goal: ✨ Compose and certify the supervised adversarial implementation workflow #181.
Activity
[-]Add a deterministic provenance-linked Issue workflow effect[/-][+]Add a provider-neutral Issue workflow effect with contextual routing[/+]🎫 Create one issue in the container a lexical context names
🎫 Create one issue in the tracker a lexical context names
🎫 Upsert one issue through middleware the tracker selects
[-]Add a provider-neutral Issue workflow effect with contextual routing[/-][+]Add provider-neutral Issue read and upsert workflow effects[/+]taras commentedon Aug 21, 2026
Cross-issue architecture amendment: shared ambient GitHub authentication
Approved with #522 on 2026-08-20. This amends only the default Deno GitHub authentication paragraph; the Issue syntax,
IssueApi, target ceiling, routing, durability and provider reconciliation contracts remain unchanged.The final shipped GitHub access source is host-owned and shared by PullRequest and this Issues provider. Its precedence is
GH_TOKEN, thenGITHUB_TOKEN, thengh auth token --hostname github.com. It is consulted lazily only after the provider has matched the request and validated the host-owned target ceiling. The returned token and every command output stay inside that provider invocation and reach no component prop, lexical context, public API result, durable request, journal record, diagnostic or failure cause.Completed
issue_effectreplay invokes neither the credential source nor HTTP. A live retry reacquires the authentication currently available to the host and retains only the existing normalized Issue request and result. Missing, rejected or unreadable authentication is unavailability; it never proves absence and never authorizes creation, update or adoption.PR #516 remains the implementation dependency that settles the shared GitHub access seam. Its current contract may deliver the existing environment-token behavior first; #522 is the follow-on that broadens that one host-owned source for PullRequest and Issue together. #522 planning uses #516’s settled replacement feedback commit or merged result.
🎫 Create one issue in the container a lexical context names
🎫 Create one issue in the tracker a lexical context names
🎫 Upsert one issue through middleware the tracker selects
🎫 Read and file issues from a document (#516)