Issue Tracking #

View this page as Markdown

Issue Tracking is an issue tracker with no user interface, operated by Turn Zero Cloud. People, agents, and programs file reports of what does not work or what is missing, rate their experience, and work each report through to an outcome. Every action is a call made from the caller's own tools. The package states the service's contract and includes a client and a test double for an application's backend and its tests.

Use it when #

Use Issue Tracking when an application needs one queue for bug reports, feedback, the checks its test runs record, and ratings that agents file and read as readily as people do. Declare the issue_tracking service in the manifest, and the platform creates one space, the set of records one owner keeps, shared by both of the application's environments. Your backend reaches the space through the platform's egress gateway, which presents the space's credential for it.

Use it for an application's own end users: the backend files each user's report under the user's identity and reads the queue through the client. When your backend files a report for one of your users, send that user only their own report's state. Do not send the call's whole answer: the issue in it holds other reporters' words. Your tool reads the whole space, files into it, rates in it, and settles its reports through the platform's feedback actions, naming the space (Feedback in your application's space).

Do not use it for work that needs boards or sprints, for notifications, or for fields your application defines. The service sends nothing, and an issue moves only through the calls and the passes, along one fixed set of allowed moves between its states. The passes are the AI runs that match new reports to known issues (What the triage passes do). The passes run on Turn Zero's own AI allowance, never on your application's. They read a space only where its configuration turns them on, through the contract's configure call (Configure the space).

What it provides #

  • Spaces. A space contains one owner's records and is identified by a universally unique identifier (UUID). Each of its tokens has one of four permission levels, from lowest to highest file, work, record, and operator, one token per holder. Every call made with a token reads and writes that space alone. Keep a file token in your backend and give it to no end user, device, or client: its holder names the reporter each call is for.
  • Attributed writes. Every write names the person, agent, or program that acted. A service that relays its users' actions names each user, never itself, and a relayed read returns that user's own reports and nothing of anyone else's.
  • Issues and reports. An issue is the one curated record of a problem. It has a short identifier such as #12, a kind, a state and an outcome, a priority, a component, what it waits for and who claims it, and the counts of its reports. A report is one filing in the filer's words, never edited, linked to the issue with the reason for the link.
  • The kinds. bug, gap, docs, friction, question, task, praise, and idea; problem, error, and mistake file a bug. An idea takes no priority and is never fixed, and next never hands it out. Later requests link to it, so its report count is its demand.
  • What an issue waits for. waiting_for names who or what it waits on: null, person (the space's owner), person:<account> (one member of a shared space), reporter, outside:<what>, or evidence, which stands exactly while the issue is waiting. An issue waiting for a person, the reporter, or an outside event stays open, and nothing closes it on its own. list and count filter and group by it and by the claim, none selecting the issues with no value.
  • Who claims it. claimed_by names who works the issue: null, or a label of 1 to 200 printable characters. next alone writes branch:<name> with its lease; a session, an agent, or another worker claims through update under any other label. A claim while another stands is refused lease_conflict, and give_back with a reason clears it: a label claim by any caller at the work level, a branch's lease only with its secret. A claim untouched for a day reads as stale.
  • Matching at filing. The service fingerprints the evidence the platform stamped on a report and links a repeat to the known issue without any AI. A report whose origin is not workspace or platform is never linked by its evidence onto a restricted issue, one marked as a security concern that the service hides from other reporters. A follow-up under the filer's own problem key, the filer's own name for the problem, is a new report on the same issue. Where the issue waits on its reporter, its waiting_for reading reporter, its reporter's follow-up clears that and returns it to the queue.
  • What a fingerprint needs. A report links by its fingerprint only where its evidence names the call's refusal. Two reports of a call that was answered, naming no refusal, are never linked by it.
  • Undoing a confirmation. Where a filer's follow of a likely match closes the issue its filing opened, which holds that report alone, as a duplicate, an unmerge of that issue also withdraws the link the follow made. The report then stands on its own issue alone.
  • A repeat of a declined request. A filing's candidates, its likely matches, include an issue closed declined or as_designed: a request considered and not taken. It ranks after an open issue that matches as well, and a closed restricted one is never offered, whatever the filing's origin. Each candidate carries its outcome, null while the issue is not closed, and a fixed sentence, such as "Not taken.", and never the resolution's words. A follow onto such an issue adds the report and leaves the issue closed.
  • Why a candidate matched. Each candidate names its issue's kind and component, and its summary where a trusted actor wrote one. Its matched_evidence names the members of the filing's evidence that equal those of the standing reports whose evidence chose the issue, and is empty where only the title's words matched.
  • Clearing a resolution. On the service's current calls, an update naming resolution: null clears the resolution a new or open issue carries, and that update writes none. settle and wait write one. The update that createIssueTrackingClient makes still writes a resolution, on any state.
  • Amending your own report. A filer corrects or withdraws a report it filed with submit naming amends, the report's number, never the issue's. The amendment is a new report that has amends and withdraws. It is linked via: amendment to each issue the first report belongs to at that moment. So the triager reads the two side by side, and the first report stays as filed. Naming another actor's report is refused report_not_found, as is a number that matches no report.
  • What an amendment names. It names at least one of title, text, impact, workaround, proposed_resolution, attachments, and withdraws. Its kind and problem key are the first report's, and so are its title and impact where it names none. A withdrawal moves nothing by itself: a person reads it and settles the issue. createIssueTrackingClient has no amends, so a backend amends through the gateway with createIssueTrackingClientV2 (Methods and results).
  • Images on reports and comments. A filing and a comment can each carry up to four images, PNG, JPEG, or WebP, each at most 512 KiB, judged by their bytes and never by a name. The service stores each beside its report, under the report's own privacy and restriction, within 16 MiB a space. A read lists each image as { id, type, bytes, sha256 }, and GET /v2/attachment/<id> under a work token fetches it. A retry stores no image, an operator's redact removes one whole, the retention drop takes a report's images with its text, and no AI pass reads one.
  • A note to reporters. An issue can carry a note of up to 2,000 characters for all its reporters, which a read naming a reporter returns. An update under a work token or above writes or clears it on any state, and no filing sets it. Never quote a report in it, or name a reporter or a restricted issue. A fixes or resolves landing writes a note naming its commit and when it landed, which a reporter reads only while the issue is fixed or closed done or verified. Through the gateway, only createIssueTrackingClientV2's reads return it.
  • Praise apart. Praise and every other kind are kept on separate issues. A report filed as praise opens or joins a praise issue, and a report of another kind joins no praise issue, even under a problem key once used for praise. Where a praise report and a problem's issue meet, the service relates the two issues, unless either is restricted, and links neither report across. No merge joins a praise issue with an issue of another kind, and a praise report never reopens a fixed issue.
  • Mentions become relations. On the service's current calls, a comment, or an update that sets the summary, relates its issue relates_to to each other issue its text names, such as #12. The service makes each relation itself, at most five a write, and the answer's mentioned lists them. It skips the issue itself, a number with no issue, a pair already related or unlinked, and a restricted issue beside one that is not. A quoted line opening with > links nothing, nor does a number inside a word, a path, or an entity. A report's text links nothing.
  • Who waited on a settled issue. On the service's current calls, settle answers related_open: up to 20 entries, one for each blocks, part_of, or relates_to relation to an issue not closed, in either direction. Each entry names the relation's type, from, and target, and the other issue's state. blocks comes first, then part_of, then relates_to, each by level and then by number. A restricted issue is listed only when the settled issue is restricted too. Where 20 are listed, read the issue for the rest.
  • Builds and checks. A build records a version of a release line, the sequence of versions an environment runs, written by the deploy. A check records each run of a monitored job under a stable key, and an issue opens from it only once the failures repeat. An issue a check opens reads blocked where the check breaks after at least three green runs and the failure is in the product or the client. A build that names no line counts toward neither. A landing records the commit that fixed an issue, with its regression test and proof.
  • Withdrawing a run. The job that posted a check's run can withdraw it by the key it posted it under. The withdrawal stays in the check's history and closes an episode the run opened. A key given with it is the withdrawal's own retry key, never the run's.
  • Verification by exposure. A fixed issue closes verified once live builds that contain the fix have exercised it a set number of times, with no report of the same point since. An issue nobody can explain waits, and the next report reopens it. A bug or a friction closed done by hand, with no landing, names in fixed_in the build that serves the fix where a returning reporter filed on it, and may name it otherwise. A later report then reopens the issue only when it comes from that build or a later one, and with no fixed_in it reopens nothing.
  • Passes. Two AI passes propose and check duplicates, splits, and themes, and the triage pass judges each new issue to give it a priority from 1 to 3. A merge is applied where they agree and the evidence on at least one side was stamped by the platform or confirmed against its records. A pass never edits a report, and never moves a priority a person or a program wrote. How an issue gets its priority explains each rule.
  • What holds a merge. A merge the passes agree on waits while its duplicate carries a plan, is fixed, or carries a branch's claim. Once the duplicate is free, the passes apply the merge at their next write, or withdraw it where it no longer fits their limits. An agreement lapses where the master closes, a report joins either issue, or a component is set. A merge withdrawn because its master closed as a duplicate is proposed again against the surviving master. A person's settle applies a merge held by a plan at once, and so does createIssueTrackingClient's settle with the act approve.
  • What the passes propose. A read without actor answers proposals: each merge, split, or theme the passes proposed that still waits and reaches the issue. A merge reaches its duplicate and its master, a theme its issue, and a split the first issue its report is linked to. Each entry carries its id, its type, its other end, the passes' verdicts, its disagreements, its instant at, and restricted. list with proposed: true selects the issues a waiting proposal reaches.
  • How often the passes' merges were wrong. read_space returns sampled_merges, { rate, audits }: the share of the last thirty days' audited merges judged wrong, 0 where none was audited, and how many verdicts it counts. While rate is above the configuration's propose_only_threshold, an agreed merge waits for a person. relay_issue_act forwards read_space only under an owner token.
  • Ratings and asks. The human series holds the standard Net Promoter Score question, scored 0 to 10, and the agent series an agent's rating of how difficult its task was, scored 1 to 5. The two are never combined. An ask records a request for a person's rating, and a throttle limits how often one account is asked.
  • A space's figures. count returns how many issues have the state, outcome, kind, component, label, waiting_for, claimed_by, level, or requirement you name, leaving out issues with labels you name. It returns a total, or totals by any one field except the requirement. rates returns how many issues were filed, closed by outcome, reopened after a fix, and still open, per day or week over up to 400 steps. It also returns the time to close by kind and level. Both take an operator token. relay_issue_act forwards both under an owner token, and the gateway forwards neither (Keep your own issue list).
  • Counting through the gateway. A deployment counts the issues its list answers. It pages list with limit: 100 until the answered cursor is null, since a page can hold fewer issues than its limit while more follow. A large space is counted over several minutes, its pages paced within the space's rate. From development a list names its actor, so that count holds the actor's own issues alone.
  • Reads and erasure. List, search, and export read a space. Erasure replaces a person's identity on every record and keeps every report's text, so leave out of a report anything you do not want kept.
  • A client and a test double, which The client and Test with the double describe.

States, permission levels, and moves #

An issue is in one of five states: new, open, waiting, fixed, and closed. A filing opens it new where the space's passes run, and open where they do not, the default. Triage, or an update that sets its kind or component, moves a new issue to open.

wait parks a new or open issue as waiting, with what would settle it, as a green check does for the open issue it opened. With waiting_for set to person, person:<account>, reporter, or outside:<what>, it sets what the issue waits for and moves no state. An update giving a waiting issue any other value is refused state_conflict, reopen the way out.

A fixes or resolves landing recorded with land moves an issue to fixed or closes it done. A narrows landing leaves its state, and a revert reopens it. An operator's settle closes an issue with its outcome, and reopen moves a fixed, closed, or waiting issue back to open. A new report, or one a merge moves in, reopens a waiting issue or one closed unexplained.

A closed issue carries one outcome: verified, done, as_designed, declined, duplicate, superseded, noted, or unexplained. A fixed issue closes verified once live builds have exercised the fix enough times with no report of the same point, and one nobody can explain closes unexplained after the long wait. A praise filing that opens its issue closes it noted at once.

Each token has one permission level, and a level allows its own calls and those of every level below it:

  • file files with submit and confirms a repeat with follow, for the reporter each call names.
  • work reads, lists, and searches issues, sets an issue's curated members with update, comments, relates, and splits. It sets what an issue waits for with wait, takes work with next, gives work or a label claim back, and records ratings and asks.
  • record confirms a report's evidence and records landings, builds, shipments, exposures, and checks.
  • operator settles, reopens, and unmerges issues, clears restricted marks, redacts, reads counts and rates, exports, erases an actor, reads and restores the record, configures the space, mints and revokes tokens, and deletes the space.

Restricted issues #

The service hides a restricted issue from a reporter and from no one else. Below the work permission level, and in every read that names a reading actor, a restricted issue is never returned. From the work level up, every read returns it as it returns any other issue. A filing whose origin is not workspace or platform is offered no restricted issue as a likely match, under any token. The one exception is a filing under an operator token that names its caller's relayed_level as work or above, as the platform's relay does at the contribute level.

A filing marked restricted links only to restricted issues, and otherwise opens its own restricted issue. A later confirmation of its report links it only to restricted issues too. A filing kept back from a restricted issue opens its own issue restricted as well, and so does a report a later confirmation keeps back, where its issue holds that report alone.

Where a report is kept from a restricted issue closed done or verified, the daily sweep surfaces that issue once with the label recheck and leaves it closed. A person reads it and reopens it or leaves it.

Code that reaches the service with its own token reads these answers:

  • Under a work or record token, list, search, and the counts of read_space include restricted issues.
  • A list that names actor answers no restricted issue, whatever the token, as the reporter's own view never does. With proposed: true it selects no issue by a proposal that reaches a restricted issue.
  • A read's proposals entry that reaches a restricted issue carries restricted: true. The calls that answer it take a work token, so a reporter never reads one.
  • On the calls createIssueTrackingClient makes, an issue record carries restricted, true or false. Their update sets no restricted, and is refused invalid_request where it names one.
  • On the service's current calls, an update that sets restricted to false is refused grant_required under a token below operator, before the key is read. Setting it to true needs a work token.

The restricted mark follows the words. When one issue is merged into another, the issue kept is restricted where either was, and its merged entry carries restricted: false where the merge set the mark. A split from a restricted issue opens restricted, a person's as well as a pass's. An unmerge leaves each issue's mark as it stands.

The triage pass looks for a restricted issue's likely matches, and offers a restricted issue among another issue's. A merge or a theme between a restricted issue and one that is not waits for a person, and the passes withdraw it where it no longer fits their limits. A merge or a theme of two restricted issues follows the usual limits.

The second reader is the passes' second reading of an issue's priority and of a closed issue's restricted mark. It reads a restricted issue closed declined or as_designed after its other readings in the critic's run. Where both its readings find no exposure, it sets restricted to false in one updated entry. Otherwise it writes one kept entry, a history entry recording that it kept the mark, and the mark stays. After a person redacts what it found, it reads the issue again. The calls createIssueTrackingClient makes return no kept entry.

When a pass gives up on an item, it writes a critic_failed entry on the item's issue. The entry names the item and why: three failed attempts, or 36 hourly runs with no answer from the model. An item given up on for want of an answer is still tried once each run, and the first answer written for it adds a critic_cleared entry. The calls createIssueTrackingClient makes return neither entry.

How the gateway keeps both environments apart #

The space is created when the manifest is first submitted. Your deployments in each environment file into it and read from it, and so do your local runs, under the development credential. The submission result's issue_tracking list gives the space, and never a token. An application that has a space for each environment keeps both until it is deleted, its production space created at its first promote, or its first production deploy where it has one environment.

The gateway keeps the two environments apart in three ways:

  1. It stamps every report your backend files with the environment the call came from and the version it serves, local from your own machine. On the first client's submit these are evidence.environment and evidence.version. On createIssueTrackingClientV2's they are one seen_in entry, { line: null, version, environment }, in place of any the call names, and an amendment carries none.
  2. It puts development: in front of every person, agent, or program id a development call names, so a development tester never appears as one of your production users. From development, a list, list_signals, or export without actor is refused invalid_request, as are read and search on either client. So is a development write that names another issue by its id, and an id that already starts with development: or passes 200 characters once prefixed.
  3. A deployment reaches the space at its binding's grant level: report for the application's own space or a space for each environment, and report or contribute for a space of your account. At report it files reports and reads the space, and the gateway refuses issue_tracking_call_refused every call that changes an issue: update, comment, relate, settle, signal, export, erase_actor, open_ask, and close_ask.

Every filing on createIssueTrackingClientV2 reaches the space with the origin field, whatever it names, so the service reads it as a reporter's. One naming an evidence_basis other than claimed is refused invalid_request, and so is one naming relayed_level.

At report, the gateway leaves a restricted issue out of every response, whichever space the binding names. A list or a search skips it, and a read of it is refused as for an issue the space does not hold. The response to a filing or an amendment names no restricted issue. A read's proposals leave out each entry that reaches one, and a reporter's own view never holds one. The counts read_space returns include restricted issues, because the gateway forwards a count as it comes.

At report a list that names proposed is refused issue_tracking_call_refused on either client; its other filters still work. A read at report leaves out each history entry that records a proposal. On the first client's calls there, every issue's proposed reads false, and a read leaves out a relation that is still only proposed.

A finding is an issue that groups other issues, its members, stored as a task labelled finding. On the first client's calls at report, a finding's members list only the issues that are in it. A finding whose own proposed field is true is skipped as a restricted issue is, as is one a proposal made and nobody approved.

The first client's list and search also skip a finding the passes made that a person approved and later declined by hand, though a read of it still returns it. On createIssueTrackingClientV2 the gateway skips every finding with no report of its own, approved ones included, so a backend at report sees no such finding. Read findings at a higher grant level.

The contribute grant level adds the working calls, and owner reaches every call; no deployment has owner. The call that starts a deploy may name commit, 7 to 64 lower-case hexadecimal characters. After each deploy, promote, and rollback, the platform writes a build record into the application's own space naming the version, the environment, and that commit, never delaying the deploy. A malformed commit, or one on the call that only prepares an upload, is refused invalid_request. An application with a space for each environment gets no build record.

A space of your account's own #

A manifest can instead name a space your account created with create_issue_space, and the platform binds the application to it under the same three rules. Bind an application to the space gives the space and level to name, the release line the platform adds, and their refusals.

Deletion and copies #

The platform keeps the space's token in custody under a platform-minted name, and no application and no author has it. A manifest revision that drops the kind changes nothing: the space and its records remain. The platform reads a space's records only through the feedback actions naming the space, the export, the deletion of your account, and the daily copy.

Deleting the application deletes its own space with every record in it, and ends a binding to a space of your account's own. Deleting your account deletes every space of its applications and its own. Deleting the development environment deletes nothing of a shared space, ends a development binding, and deletes a per-environment development space. A deleted space cannot be recovered.

Once a day, the platform copies each space your account holds and each application's own space into storage it holds for your account. A copy is kept 35 days, the newest until a newer one is written, and a space's copies are deleted with it. Per-environment spaces are not copied, and no action reads or restores a copy.

The daily copy lists a report's or a comment's images and carries none of their bytes; a copy the contract's read_record answers carries them, and a restore from it brings a redacted image back. Deleting a space deletes its images with it, and an operator removes one image whole with redact.

Availability #

Version 0.31.0 is the package's current version, and this page describes it. Until the library's next publish, list_library and read_library_entry can return an earlier version, the one you can take, so this page can describe a change that version lacks. The entry includes the testing subpath from version 0.29.0. Under the package's earlier name, issue_tracking, the subpath stood from version 0.1.0. The entry contains the compiled clients and double as the npm package @turnzero/tztrack, the package's product statement and detailed contract, and its integration guide. The contract's "Migration to" sections list what changed at each version that moved a promise.

Turn Zero Cloud runs the operated service and creates one space for every application that declares the kind. The calls, the records, and the spaces' stored bytes count against no plan quantity, and no charge attaches to a space, a call, or a record (Plan and usage). The service limits each space's forwarded calls per minute, and a call past the limit is refused rate_limited. When many reads of large spaces run at once, the service can also refuse a read rate_limited: wait a few seconds and call again.

The tztrack package depends on no other feature package. Storage states the rule this service applies to every write: where a service relays its users' actions, each write names the user who acted. Account verifies the end users an application names as actors.

The client #

The package is published in the library as the npm package @turnzero/tztrack, not on the npm registry; Use the library installs it with one line. The module holds two clients: createIssueTrackingClient, the one a hosted backend uses for most calls, and createIssueTrackingClientV2 for the service's current calls. The gateway forwards four of the current calls, submit, read, list, and search, so your backend can amend its users' reports. A relay of your account reaches the rest.

Construction #

createIssueTrackingClient(transport) builds the hosted backend's client, with no options. transport(path, init) sends each call to POST /egress/v0/issue-tracking/v1/<call> under the application's platform credential, TURNZERO_CLOUD_APP_TOKEN, and returns the response. The path sits on TURNZERO_CLOUD_GATEWAY_URL where the deploy sets it, and on TURNZERO_CLOUD_API where it does not. The gateway finds the space from the calling application and environment, and presents the space's token for it. A local run reaches the same space through the platform's public gateway under the development credential, and its reports are stamped local. A test runs the double in its place (Test with the double).

createIssueTrackingClientV2(transport), also with no options, runs over the same transport, each call at /egress/v0/issue-tracking/v2/<call>.

Methods and results #

The gateway forwards submit, read, list, search, read_space, list_signals, and read_ask at the report grant level, each a method of the client, such as client.submit(request) and client.listSignals(). Each method takes { signal } as its last argument.

  • submit({ actor, kind, source, title, body, evidence, key }) files one report, actor being { kind, id } for the user you file for. It returns { issue, outcome }, the outcome one of filed, retried, repeated, reopened, and recovered.
  • read(issue) returns the issue, its history, comments, relations, and members. list and search return issues, list in the order newest or score, the service's rank by an issue's reports.
  • On this client, a priority is 1 to 4. excerpt may only be false, and true is refused invalid_request. recovered: true is refused recovery_unmatched unless that actor's own filing under that key opened the issue and every report on the issue is that actor's.

On createIssueTrackingClientV2 the gateway forwards submit, read, list, and search at both grant levels, and refuses every other call issue_tracking_call_refused. Make those calls with the first client.

  • submit({ actor, origin, kind, title, text }) files one report, and returns its report, whose id is its number, with the issue and the outcome. The gateway files it with the origin field, and the service refuses a question or a task from that origin, so file those with the first client.
  • submit({ actor, amends, text, withdraws }) corrects or withdraws a report that actor filed, amends being its number. It returns the outcome amended.
  • read({ issue, actor }), list({ actor }), and search({ query, actor }) return that user's own view: the issues their reports belong to, each with their reports and each report's amends and withdraws.

Failures #

  • IssueTrackingGatewayRefusal, for the gateway's own refusal, which the client tells apart by the marker header. issue_tracking_call_refused is a call the gateway does not forward, and issue_tracking_application_required a credential that names no application. issue_tracking_not_provisioned is an environment with no space, and issue_tracking_unavailable a token that cannot be read, which may be retried.
  • IssueTrackingRefusal, for the service's refusal by name in refusal, with status and detail. A refused call changes nothing. Its own is null except on a read naming actor refused issue_not_found, where that user's own report joined the issue the number names. There own is the identifier of the issue that report is on now. A read naming the same actor returns that issue. own never names an issue that user cannot read. It is null for another user's issue, for a restricted issue, and for a number that names no issue.
  • IssueTrackingUnreachable, where the transport failed or the service answered a fault of its own. Retry with the same key.

Branch on refusal, never on detail. The refusals page lists the gateway's four refusals with their cause and remedy. The service's own names, recovery_unmatched among them, are listed in the package's contract, the entry's tztrack.md.

Test with the double #

createIssueTrackingDouble(options) from @turnzero/tztrack/testing answers both clients' calls in memory, and passes the same list of test cases the contract states. Seed a space with double.createSpace(), then take double.gatewayView({ space }) over its space.id: the gateway's view, as the deployed backend reaches its space. Build createIssueTrackingClient(view.transport) with no options, and createIssueTrackingClientV2(view.transport) for the four current calls the view forwards. reset() empties every space, so seed a space and take its view in each test. A test runs this sample over the double.

import assert from 'node:assert/strict';
import { createIssueTrackingClient, createIssueTrackingClientV2, IssueTrackingGatewayRefusal } from '@turnzero/tztrack';
import { createIssueTrackingDouble } from '@turnzero/tztrack/testing';

// One space, reached as the deployed backend reaches it: through the gateway's view.
const tracker = createIssueTrackingDouble();
const { space } = tracker.createSpace();
const view = tracker.gatewayView({ space: space.id });
const client = createIssueTrackingClient(view.transport);

// The backend files the signed-in user's report under the user's own id.
const filed = await client.submit({ actor: { kind: 'person', id: 'user-1' }, kind: 'bug', source: 'person', title: 'Export does nothing', body: 'Clicking Export downloads no file.' });
assert.equal(filed.outcome, 'filed');
// Answer the user with their own report's state alone, never the whole issue.
const reply = { issue: filed.issue.id, status: filed.issue.status };
assert.equal(reply.status, 'open');
// The same transport carries the current client's filing and the user's own amendment.
const current = createIssueTrackingClientV2(view.transport);
const first = await current.submit({ actor: { kind: 'person', id: 'user-1' }, origin: 'field', kind: 'bug', title: 'Import fails' });
const amended = await current.submit({ actor: { kind: 'person', id: 'user-1' }, amends: first.report.id, withdraws: true });
assert.equal(amended.outcome, 'amended');
// A call the gateway refuses throws its own refusal, told apart from the service's.
view.refuseNext('issue_tracking_unavailable');
await assert.rejects(client.list(), (error) => error instanceof IssueTrackingGatewayRefusal && error.refusal === 'issue_tracking_unavailable');

The client's refusals are those Failures lists. These come from the double's own setup:

Refusal Cause Remedy
issue_tracking_call_refused, issue_tracking_application_required, issue_tracking_not_provisioned, issue_tracking_unavailable view.refuseNext scripted the gateway's refusal, or the call named a path the gateway does not forward, such as delete_space. Call through the client, and assert the refusal where the test scripts it.
issue_tracking_not_provisioned, unscripted The view's space does not exist: it was never seeded, or reset() emptied it. The view returns this refusal on its own for every call to a space that does not exist. Seed the space with double.createSpace() and take its view again in each test.