# AnyKanban Technical Documentation

This page is the developer/maintainer reference for **AnyKanban** (publisher id `AnyKanban`, extension id `query-kanban`), an Azure DevOps web extension that renders any saved work item query, whether flat, tree or direct-links (link results are flattened to their distinct targets, ADR-035), as a fully interactive Kanban board or a reorderable backlog-style list, with named boards in a dedicated Boards hub and an ad-hoc board tab on query results. Both of those are **ids, not names, and never change**: the Marketplace identity is `publisher.extension`, so changing either half would make a new extension that loses every installation and review. The extension id is the working name the product was built under. It is a purely client-side extension: no backend service, all reads/writes go from the extension iframe to the Azure DevOps REST API under the signed-in user's identity, and all settings live in the Extension Data Service. For setup, see [Prerequisites](https://anykanban.com/docs/prerequisites/). The Gherkin feature files under `e2e/features/` are the executable specification of behavior.

## Overview and contribution surfaces

Six contributions share one webpack build, the query page's two tabs sharing one entry; a further entry exists for testing/demo only and is excluded from production builds.

| Surface | Contribution | Type | Target | Entry |
|---|---|---|---|---|
| Boards hub (primary) | `query-kanban-hub` | `ms.vss-web.hub` | `ms.vss-work-web.work-hub-group` | `dist/hub/hub.html` |
| Query results tab, Board | `query-kanban-tab` | `ms.vss-web.tab` | `ms.vss-work-web.query-tabs` | `dist/query-tab/query-tab.html` |
| Query results tab, Aging WIP | `query-kanban-tab-aging` | `ms.vss-web.tab` | `ms.vss-work-web.query-tabs` | `dist/query-tab/query-tab.html`, the same entry: the frame asks which contribution opened it, and the id chooses the view (ADR-100) |
| Dashboard widget | `query-kanban-widget` | `ms.vss-dashboards-web.widget` | widget catalog + its own configuration | `dist/widget/widget.html` |
| Widget configuration | `query-kanban-widget-configuration` | `ms.vss-dashboards-web.widget-configuration` | `ms.vss-dashboards-web.widget-configuration` | `dist/widget-config/widget-config.html` |
| Organization settings | `query-kanban-admin` | `ms.vss-web.hub` | `ms.vss-web.collection-admin-hub-group` | `dist/admin/admin.html` |
| Standalone harness/demo | not contributed | none | none | `dist/standalone/standalone.html` (dev builds only) |

Manifest facts (`vss-extension.json`, repo root): `manifestVersion: 1`, target `Microsoft.VisualStudio.Services.Cloud` (Azure DevOps Services only; the bare `Microsoft.VisualStudio.Services` would also claim Azure DevOps Server, which is not supported), and four scopes: `vso.work_write`, `vso.extension.data_write`, the read-only `vso.analytics` and the read-only `vso.memberentitlementmanagement` (`E25`, ADR-076). When an administrator presses **Count users** on the AnyKanban page in Organization settings, one hand-written client (`src/platform/memberEntitlementClient.ts`) reads aggregate access-level totals from the organization's member entitlement service. It reads no names or user list, runs nowhere else and stores nothing. `architecture.test.ts` rejects the per-user route and any second caller. Packaged files are `dist` and `static/img` (addressable) plus `static/marketplace/img` mapped to the package root as `img`, because the Marketplace resolves the overview's relative image paths from there. There is no overlay-manifest folder: the dev variant is a **complete second manifest**, `vss-extension.dev.json`, also in the repo root. `tfx` resolves `files` paths relative to the manifest's own folder, so both must sit beside `dist`.

**What ships is verified, not assumed.** `npm run verify:package` builds the real VSIX and asserts that it contains no source maps, no development-only code, nothing outside an explicit allow-list, and that every image the listing references resolves at the path the Marketplace looks for. CI runs it after the build.

**Query-tab context caveat.** The tab iframe receives the current query via `SDK.getConfiguration().query`. This shape is **de-facto, not documented** by Microsoft, and is confirmed only from production extensions in the wild. It is therefore consumed exclusively through the defensive parser `src/platform/queryTabContext.ts` (`parseQueryTabContext`), which accepts both the initial `{ query: {...} }` container and the bare query object delivered to `updateContext` callbacks, and returns `undefined` for anything unexpected. On parse failure the tab falls back to the same explicit query picker the hub uses, so a breaking change in the undocumented context degrades one convenience path to a picker click and can never take the product down. The hub, the primary surface, has no dependency on this context at all. The tab registers a contribution object (`pageTitle` / `updateContext` / `isInvisible` / `isDisabled`) via `registerContributionObject` in `platform/sdk.ts`.

**Bundling.** `webpack.config.js` auto-discovers entries: every folder under `src/entries/` containing `<name>.tsx` + `<name>.html` becomes an entry (currently `admin`, `hub`, `query-tab`, `standalone`, `widget`, `widget-config`), with a shared `vendor` split chunk so every contributed surface ships one set of vendor bytes. All JS is packaged in the VSIX, because extensions cannot load from CDNs.

Stack: TypeScript, React 16.14 (pinned by `azure-devops-ui` 2.276.0), `azure-devops-extension-sdk` 4.2.0, `azure-devops-extension-api` ~5.275.0, drag & drop via dnd-kit, state via zustand ^4.5.

## Architecture layers

```
src/
├─ entries/     admin/ hub/ query-tab/ standalone/ widget/ widget-config/:
│               bootstrap only, no logic
├─ platform/    SDK boundary and host integration (single SDK import point)
├─ data/        REST access, polling, diffing, write pipeline
├─ domain/      pure board logic and the zustand store
├─ settings/    Extension Data Service persistence
├─ ui/          React components
├─ i18n/        centralized strings
└─ harness/     in-memory mock host (BDD tests + the website demo)
```

### platform/: the host seam

| Module | Responsibility |
|---|---|
| `sdk.ts` | **Sole importer** of `azure-devops-extension-sdk` (init with `applyTheme: true`, project info, service acquisition, access token, contribution-object registration). Exposes `setSdkOverride(...)` and `isSdkOverridden()`, the harness seam that replaces all host interactions in standalone mode. Isolating the SDK in one file is the mitigation for the React 16 / SDK version lock. |
| `clients.ts` | Typed REST client construction (`WorkItemTrackingRestClient`); `setWitClientOverride(...)` harness seam. |
| `memberEntitlementClient.ts` | The one client written by hand rather than taken from the package, because writing it is what makes its narrowness checkable: it declares a single route, `_apis/userentitlementsummary` (`select=Licenses`: how many users hold each access level, meaning level names and integers and never a person, so no user names, no mail addresses and no list of who), at **api-version `7.1`**, final rather than preview, since a pinned preview band is what Azure DevOps Server 2022 rejects per call (`G7`). `architecture.test.ts` reads this file and fails the build on a second `routeTemplate`, on naming an HTTP method at all (GET is `beginRequest`'s default, and writing one down is the first step toward a write on a service that can change people's access), and on the per-user route (`_apis/userentitlements`) anywhere in shipped code, so *cannot list your users* is a property of the source rather than a promise about it. Its host is the organization's own `{org}.vsaex.visualstudio.com`, resolved at run time from the resource area id exactly as Analytics resolves its base; no host string is written down. |
| `navigation.ts` | Opens the native work item form (`WorkItemFormNavigationService`); `setOpenWorkItemFormOverride(...)` harness seam. |
| `identity.ts` | Lazily constructs the native `PeoplePickerProvider` for identity-field inline editing. Returns `undefined` when the SDK is overridden (standalone harness) or construction fails, and callers fall back to a plain text input. |
| `queryTabContext.ts` | Defensive parser for the undocumented query-tab configuration (see above). |
| `theme.scss` | Theming glue; webpack runs `azure-devops-ui`'s css-variables loader so styles follow the host light/dark theme. |

### data/

| Module | Responsibility |
|---|---|
| `queryRunner.ts` | Runs saved queries (`queryById`) or raw WIQL (`queryByWiql`). Flat results return ordered ids (`kind: "flat"`); `WorkItemLink` results, tree and one-hop direct-links alike, are flattened to their **distinct target ids in arrival order** (`kind: "tree"`, ADR-035). `UnsupportedQueryTypeError` survives as a defensive guard for a result shape that is neither. Maps the VS402337 overflow (WIQL 20,000-item cap) to `QueryTooLargeError`. Also fetches query details incl. WIQL text. |
| `hydrator.ts` | WIQL returns ids only; hydration fetches field data in chunks of ≤ `MAX_IDS_PER_CALL` = 200 (documented REST hard limit), with `WorkItemErrorPolicy.Omit` so items deleted between query and hydration silently drop out. |
| `metadata.ts` | Cached (per project/type) process metadata: the state model via `getWorkItemTypeStates` (`getStateColumns`: non-Removed states ordered Proposed → InProgress → Resolved → Completed, which are the board's columns unless the definition carries its own list), Removed-category state names, project field types (drives enumerable value combos in config), identity field names (drives the people-picker path for inline edit), and `workItemTypeHasField` (recipe and aging prerequisite gate). |
| `poller.ts` | `Poller` class: visibility-aware refresh loop at 20 s visible, 120 s hidden, ±15 % jitter, exponential failure backoff (factor `2^failures`, capped at 16). `pollSoon()` schedules a near-immediate tick: 300 ms on window focus, 500 ms on becoming visible, 1200 ms after the extension's own writes settle. A board that executes a saved query with its own `ORDER BY` (ADR-087) re-reads that query's text every five minutes and reloads when it changed, so an edit to the saved query reaches the open board without a Refresh. |
| `diff.ts` | Pure snapshot diff (added / removed / changed by rev). Only changed ids are re-hydrated. |
| `writePipeline.ts` | The concurrency-correctness core; see the Concurrency model section. |
| `workItemPatch.ts` | Pure JSON-Patch builders. Every update patch leads with a `test` on `/rev`. |
| `workItemCreate.ts` | Work item creation for inline add. |
| `queryTree.ts` | Query and work-item-type listings for the config pickers. |

### domain/

| Module | Responsibility |
|---|---|
| `types.ts` | Shared domain types incl. `BoardDefinition` (see Settings storage), `CardItem`, `ColumnModel`, `StyleRule`. |
| `boardStore.ts` | The zustand store, and the single source of truth. Holds items, per-column id lists, columns, warnings/errors, `pendingCounts`/`pendingIds` (reference-counted in-flight writes), `dragActiveId`, toasts, the board-level config (`rankField`, `columns`, configured or derived, and `columnField`, `splitDoneField`, `splitColumns`, `laneField`, WIP limits, DoD texts, style rules, tag colors, `removedStates`), the shared `relationsByItem` pass, per-card `checklists`, and the Related/rollup state (`relatedByCard`, `rollupByCard`, `rollupForest`, `rollupDirty`, `rollupView`). Actions: `load`, `pollOnce`, `loadRollup`, `moveCard`, `reorderInList`, `addCard`, `updateField`, `refreshItem`, checklist actions (`toggleChildDone`, `reorderTask`, `addTask`, `refreshChild`, `refreshChecklist`), `confirmWrite`/`failWrite` (+ child variants). Constants: `SOFT_CAP = 5000` (hydration/poll cost bound, since rendering pages separately per ADR-021), `ROLLUP_SAFETY_INTERVAL_MS = 300 s`. The store's `rankField` is the field the board's subject type DERIVES from its process (ADR-087), never a stored choice, and `cardOrder` says why reordering is on or off. Rollup refresh follows ADR-022: the descendant tree NEVER rides the 20 s tick. The tick only arms a dirty tell (planned target rev moved, planned set changed) and `loadRollup` re-reads the whole forest (1 recursive WIQL + 1 state batch, constant) on load, on the tell, or after the safety interval. A confirmed checklist STATE write on a child some rollup counts (`confirmChildWrite`, bug 6939) arms the same tell, because the confirm advances the child's rev and no later tick would see it move. |
| `rank.ts` | Ordering math over the order field (a double, ascending), and the rules of one order field per process (ADR-087): `deriveOrderField` (the process's Order field when the type carries it, else the other platform field, else none), `queryAdmitsRank` (no sort, or the field ascending first), `rankFieldEligible` (the item's own (project, type) derives the board's field), `orderProjectOf` (the one resolution of the project an item's order field is judged in: its own non-empty project, else the board's; bug 7316), `rankIneligibility` (why an item is not eligible: another field, unreadable, or none; `undefined` exactly when it is eligible) and `cardOrderState` (the one answer the store, the card menu and the settings row share). `rankBetween` = midpoint insertion (open bounds extend by `RANK_STEP = 1e6`); `isGapExhausted` detects gaps below `MIN_GAP = 1e-4` or unrepresentable midpoints; `respacePlan` respreads a ~20-item window evenly between the ranks of the loaded cards just outside it (or, on a side with nothing loaded beyond it, the window's own end card, which keeps its rank), widening while those bounds are too tight; unranked cards other than the one being placed are never members, and a card the board may not write is a fixed bound the window never crosses. It hands an open side back to the store, which asks the service for the true neighbor, leaving the dragged card out (`data/rankNeighbours.ts`, where a read that fails, a throttled one included, refuses the drop as unchecked and is never taken as an empty side), and finishes with `respaceWithin`: inside the true gap, by the minimum step past a lone bound, or a refusal when two known bounds leave no room. Before any rank is written, the store reads the items of the same project and of the respace's types (`respaceTypes`, bug 7347) strictly inside the span the plan touched (`respaceReadSpan`, then `ranksInside` in `data/rankNeighbours.ts`: one WIQL and one hydrate, at most 200 items of those types whether the board shows them or not), and `replanAroundUnshown` places the window again with every item the column does not hold as a fixed point. A settled plan's span over the cap is read again from a window of three cards around the drop. An open plan's is not, because it already spans the run between fixed cards. A plan with no room around them refuses the respace with `toast.respaceRefused`. A span still over the cap refuses it with `toast.respaceTooMany`, which invites no retry because the same drop meets the same cap, and a failed read refuses it with `toast.respaceUnchecked`, which says only that the order could not be checked. A throttled read counts as failed and is not waited out: a read is refused while the gate is closed, never queued (ADR-022), and the retry the toast invites works once the pause ends. On a side whose neighbour read found nothing, the span read takes in the window's own edge rank as well, so an item tied with it is a fixed point too (bug 7307). |
| `styleRules.ts` | Pure evaluator for card styling rules (ordered, first match wins, AND-ed clauses, `@Today` ± day arithmetic and `@Me` macros); an unparseable clause makes its rule non-matching instead of failing the board. |
| `grouping.ts` | The one answer to "which column does this card belong to": `columnOf` resolves state → candidate columns → the placement field's choice among them → the first as fallback, and `groupByColumn` groups query-ordered ids with the same rule. Also `buildColumns` (configured columns joined with the live state model, color and category from the model), `stateFor` (the write side, meaning the state a column implies), and `isMultiColumn`. Unknown states land in the reserved `UNMAPPED_COLUMN` (never silently dropped); Removed-category states are excluded from the board grouping. |
| `columnLayout.ts` | What makes a configured column list unstorable, as messages fit to show: duplicate or empty names, a column with no state, the reserved name or a control character in one, a shared state with no placement field, one field bound to two jobs, plus, for the caller that has the state model, every state covered and Completed columns forming a suffix. One rule set for the configuration panel and `saveBoardDefinition` alike, which is what makes import obey the panel's limits. |
| `placementFieldCandidates.ts` | Which of a type's fields may be bound as a placement field (the column field, the split done-flag): custom fields only, never `WEF_*`, never one the board already writes elsewhere; the conventional names (`Custom.BoardColumn`, `Custom.BoardColumnDone`) sort first, and a stored field the type no longer carries is kept as a synthetic candidate so the board stays editable. |
| `filtering.ts` | Client-side keyword filter predicate. |
| `cardMapper.ts` | REST work item → `CardItem` projection (rev, state, tags, rank, raw fields). |
| `checklist.ts` | Pure checklist logic (E2/E3): task/bug partition by the CHILD project's type names, done = state CATEGORY is Completed (never a name), rank ordering for tasks, deterministic state-then-id ordering for bugs/tests. |
| `rollup.ts` | Pure rollup arithmetic (E6): `judgeSubtree` judges one linked root's subtree (counted/excluded per node, structural direct-children fallback), `computeCardRollup` aggregates a card as a UNION over distinct node ids, contributing-first, with counted > judged > excluded claim upgrades so link order never changes the bar, while per-root rows keep each root's own full truth. The per-level strip covers every root's judged nodes, each once, from a best-judgment union of its own (the count's union only fills ids in its second pass, so reading the strip from it made the strip depend on link order), while the count covers only the roots `rootsOutsideCount` does not name: beside a root with requirement-level structure, a root without any adds nothing to the total (bug 6911). The slot's tooltip names only `rootsWithNothingToAdd`, the outside roots whose structure could all be placed: a root holding a node in another project or an unreadable node, or whose process configuration is unreadable (`structureUnknown`), is outside the total without being said to have nothing. `slotIconType` is the last level of the count's own nodes, which both slots draw as the unit icon. The flyout renders the same judgment the numbers came from. A node is judged by the state categories of its root's process, and the store completes them first with `completeStateCategories` (`data/backlogConfiguration.ts`): `workItemTypeMappedStates` lists no Removed-category state for any backlog type (measured 22.10.2026), so a removed descendant read as one its process cannot judge until the type's own state model filled that state in (bug 6931). One read per type holding such a state, cached for the tab. A type on no backlog stays unjudged, as before. |
| `wiqlPrefill.ts` | Parses the bound query's WIQL into field prefills for inline add, flagging uncertainty when the query cannot be satisfied automatically. |

### settings/

| Module | Responsibility |
|---|---|
| `settingsService.ts` | `SettingsBackend` abstraction over `IExtensionDataService` document collections, with `setSettingsBackendOverride(...)` as the harness seam. `saveDoc` implements `__etag` retry-with-merge (max 3 attempts): on a version conflict, re-read the fresh document, merge, retry; the default merge keeps local values and adopts the fresh etag. Missing collections/documents read as empty instead of throwing. |
| `boardDefinitions.ts` | CRUD for `BoardDefinition` documents: collection `boards`, stored either in the **shared (`Default`) data scope** that everyone who opens AnyKanban in the project sees, or in the signed-in user's own **`User` data scope** for a personal board. `listScopedBoards` reads both on load, filters by `schemaVersion === 1` and `projectId`, and sorts by name within each scope, shared boards first. Save and delete take the scope, and both default to the shared one. `newBoardDefinition` seeds defaults (UUID id, default card fields); no rank field is stored, because the order field is derived from the process at load (ADR-087). `saveBoardDefinition` is also the single choke point where reserved field names (`WEF_*`) and unmeaningful column layouts are rejected, and every writer passes through it, import included. |
| `baselineStore.ts` | Board baselines, collection `baselines`, **`Default` scope**, one document per board key (the definition id, or `query:<id>` on the query tab). `getBaseline` reads through `sanitizeBaseline` in `domain/baseline.ts`, the only reader. `saveBaseline(next, scope, known)` re-applies a write on an etag conflict by its scope (a board baseline replaces the stored one, a lane baseline puts its entry into the fresh document so a colleague's other lanes survive) through `mergeBaselineWrite`, the conditional merge: it compares every stored entry in the write's scope with the one in `known`, the document the reader had on screen, by identity (`setAt` and the setter's id, else name), and when any is another set, or a lane `known` lacks, it throws `BaselineConflictError` carrying what is stored, so the board shows it and asks again instead of replacing a baseline nobody here saw. `clearLaneBaseline(lane, current)` saves the document without that lane through the same merge; clearing the LAST lane leaves an empty document rather than deleting it, because the service's delete takes no precondition and a delete after the save would remove a colleague's set landing in between (an empty document reads as no baseline). `clearBoardBaseline(key, onScreen)` first saves `onScreen` back under its own etag through the merge, so a baseline set after the read stops it with `BaselineConflictError`, and only then deletes the document. `deleteBaseline` deletes unconditionally and is what the hub's board delete calls. The snapshot, the marks and the departures are pure functions in `domain/baseline.ts`, and they place an item in a lane through `laneAssigner`, the one lane assignment the store and the board also use. |
| `userPrefs.ts` | Per-user view preferences, collection `prefs`, **`User` scope**: one document per board, keyed by the board id or, on the query tab, by the query id (`viewMode`, `collapsedColumns`, `collapsedLanes`, `listColumnWidths`, `rollupView`, `suppressedWarnings`, the board notices this user silenced for good, `requestOrderSort`, this user's By request rank view, and `queryFilter`, the filter this user left on the query page's Board or Aging WIP tab for a saved query, which is how it follows them from one tab to the other: the host destroys a tab's frame when another tab is chosen, so nothing in memory survives the switch (ADR-100)), one per project hub (`hub:<projectId>`: `lastBoardId`, `defaultBoardId`, and `boardGroupOpen`, which board-picker groups are open), and one for the aging chart (`aging`: `agingIntroSeen` and `chartPercentiles`). Debounced 500 ms, fire-and-forget (losing one is harmless), merge keeps fresh fields under local ones. ⚠️ A save **replaces** the document (the field-wise merge runs only on an etag conflict), so a caller sharing a key sends the whole preference set, and the hub does this through one `writeHubPrefs` helper (ADR-050). |

### ui/

| Area | Components |
|---|---|
| `hub/` | `HubRoot` (board CRUD + JSON export/import), `BoardPickerPage`. |
| `tab/` | `TabRoot`, which parses tab context and falls back to the query picker. |
| `app/` | `BoardApp`, the shared board and list shell: view switcher, filter bar, warning banners, wires the `Poller` and the write-settled listener (`pollSoon(1200)` after each settled write). |
| `board/` | `BoardView` (DndContext, drag preview, lanes, split columns, screen-reader announcements), `ColumnView`, `CardView`, `CardFields`, `InlineAdd` (first column), `InlineFieldEditor` (click-to-edit; native identity picker inside Azure DevOps, plain text in the standalone demo), `dropIds` (encodes column/lane/sub drop targets), `CardMenu` (`useCardMenu`, the native-parity card menu of `E26`, one component for the card and every List row, ADR-090) over `CardSubmenu` + `IterationSubmenu` + `CardMenuDialogs` + `TestAdd`, and `TitleEditInput` (the title box both hosts edit in). |
| `list/` | `ListView`: dnd-kit sortable rows, reordering writes the order field through the same pipeline; under a filter the view names the visible row the dropped row lands against and the store places it in the full list (ADR-084); row drag is disabled when reordering is off (no field derived, or the query controls the order), on an as-of snapshot, for a row whose own type orders by another field, or when `rankReadOnly` is set (K1: a board of backlog items reads the backlog's order and never writes it). Every row hosts the card menu in its List scope (ADR-090): the trigger is a trailing, absolutely positioned child of the row so no column reader sees it, the menu and its dialogs are portaled inside a layer that fences pointer events from the row's drag sensor, Move to top, bottom and position go through `moveInList` (the row drag's own `reorderInList`, counted over the whole List), Move to column passes `keepRank`, and the add entries open `RowAddDialog`. An as-of snapshot hands the rows no write path, so they carry no menu. |
| `common/` | `FilterBarWrapper`, `ToastHost`, and `rankIneligibleText` (the one sentence saying why an item cannot be reordered, shared by the card menu, the List row menu and the List row's own reason; bug 7316, ADR-090 amendment). |
| `config/` | `ConfigPanel` (tabbed), `StyleConfigEditors` (style rules + tag colors). |
| `admin/` | The organization settings page (`AdminTabs`, license, usage counters, field setup, project visibility, organization defaults for new boards). `OrganizationEditGuard` asks Azure DevOps once per page whether the signed-in user holds **Edit instance-level information** (`GENERIC_WRITE` in the Collection security namespace, the bit read from the namespace by name) and, on a definite no, shows the controls that write shared settings read-only: the two defaults for new boards, the counting switch, Delete counters, and Save, Replace and Remove license. A failed or unanswered read leaves them offered. It is a guard against accidental change, not access control (ADR-096). |
| `aging/` | `AgingView`, the Aging WIP chart, and its first-run explanation. |
| `gallery/` | `TemplateGallery` and the create-board flow with its confirmation screens. |
| `widget/` | `WidgetView` and `widgetData`: the dashboard tile and its data read. |

The **ConfigPanel** is an `azure-devops-ui` Panel with a vertical tab rail grouped as native does. **General** stands alone (name, query picker, work item type picker, and the read-only **Card order** row, which names the order field the type derives from the process or says why reordering is off: the process unreadable, the query controlling the order, the type carrying neither platform field; ADR-087). Under **Cards** come **Fields** (multi-select card-field picker with filter box), **Styles** (rule editor; clause value editors offer enumerable values, picklist allowed values and `true`/`false` for booleans, as a combo), **Tag colors**, **Annotations** (whether cards show their tasks, bugs and related work items) and **Tests** (work items linked with Tested By). Under **Board** come **Columns** and **Swimlanes** (a lane field, named swimlanes with conditions where the first match wins, or lanes generated from a node of the project's iteration or area tree, one per child, re-read at every load). Saving normalizes the draft (trims, drops empty rules/tags/DoD, parses WIP limits) and writes through `saveBoardDefinition`.

The **Columns** tab is a column editor, not a per-state settings sheet: add, rename, reorder and remove columns, each mapped to one of the type's states through a picker. Several columns may take the same state, and a state that has left the type is named in the option rather than shown as a blank. Every row carries its own WIP limit, Definition-of-done text and, once a done-flag field is picked, its Doing/Done split checkbox; because the row owns them, a rename moves its settings with it and cannot drop them on a sibling. Above the rows sit the two placement fields: the **column placement field** (custom string fields of the type) and the **done-flag field** (its boolean fields), both **picked from the type's own fields, never typed**: a typo used to produce a board on which split columns silently did nothing. A type with no candidate field says so and names the fix instead of offering a half-working option, and "the list has not loaded yet" and "the read failed" are distinguished from "the type has none".

Saving **refuses** a column layout that cannot mean anything rather than warning about it (ADR-033 §6): it switches to the Columns section and shows the first problem, and the list is stored only when it says something the state model does not: a layout identical to the derived one is saved as no list at all, so a state added to the process later still gets a column.

### i18n/ and harness/

All UI strings go through `i18n/strings.ts` + `resources/en.json`, centralized keys from day one, additional locales are data-only. The `harness/` folder is described under Testing.

## Key data flows

### Initial load

1. `initSdk()` (`SDK.init({ loaded: false, applyTheme: true })` + `SDK.ready()`), project info.
2. Read the process's ordering field from `backlogconfiguration` (`backlogFields.typeFields.Order`, cached per project; a throttled read retries the load, a failed one derives nothing) and the subject type's fields, and derive the one order field (ADR-087). A flat saved query with no order of its own is then executed with `ORDER BY [field] ASC, [System.Id] ASC` added to its text.
3. Run the bound saved query (or raw WIQL). Link-pair results (tree / direct-links) flatten to their distinct targets in arrival order (`kind: "tree"`, ADR-035); VS402337 → "query too large" error; > 5000 results → truncate to `SOFT_CAP` with a warning (a tree source counts the cap against **board-level** items, scanning the flatten in bounded rounds so structure cannot starve the level).
4. Hydrate base fields (+ configured card fields, split field, lane field, and the column placement field when the board is multi-column and the type carries it, or when the board adopts a declared team's layout) in chunks of at most 200 ids.
5. Determine the board type: the configured type, else the most frequent type in the results. A card of another type stays on the board when its state matches a column. When its state matches no column, the card goes under *Unmapped*, and only then does the board show the mixed-types warning, naming the states that put it there. On a tree source with a configured type, the configured type is the boarded level: items of other levels leave the id list with a counted `treeLevelHidden` warning instead, never silently, while staying hydrated so the poll's rev diff tracks them cheaply; without a configured type the flat rules above apply unchanged (ADR-035). On a flat source whose configured type sits on a backlog level, the same holds for items of the process's OTHER backlog levels (ADR-057, Bug 6625): they leave the id list with a counted `otherLevelHidden` warning. The levels come from the board's subject project's process (`6589`), so only the subject project's items are judged; every other project's items stay, the host's included when the board names another subject (bug 7346). The load, the poll and the implicit baseline's replay ask one predicate, `hiddenAsOtherLevel` (`src/domain/otherLevel.ts`), with `boardSubjectProject`.
6. Decide card order (`cardOrderState`): on, and hydrate ranks; or off, with one notice saying why (the query controls the order, the type carries neither platform field, the configuration could not be read). Read each (project, type) on the board for the field it derives, so a drop can tell an eligible card from one that orders by another field. The query-sort notice waits for K1 and both opt-ins (bug 7553): it is raised only where K1 would not keep the order read only under any sort, which is a type outside the backlog, a backlog board holding the K8 grant (the opt-in, one type on its level, not displaced) or one holding Take priorities' grant (the opt-in on a shared board). Anywhere else the card menu explains instead, and the Card order row neither blames the sort nor offers a step no setting can complete. A type without the order field keeps 7289's answer: the sort is named first, the missing field once a sort admits it.
7. Fetch state columns and Removed-state names. The board's columns are the definition's own list joined with that state model when it has one and its prerequisite holds, and the state model itself otherwise; group ids into them (Removed-state items are hidden from the board with a count note, but the List view still shows them, because the query stays the source of truth).
8. Mark ready, `notifyLoadSucceeded`, start the poller.

### Cross-column drag with preview

`BoardView` implements the dnd-kit multi-container pattern with an explicit preview grouping:

- **Sensors:** pointer (4 px activation distance) and keyboard (`sortableKeyboardCoordinates`), with no HTML5 drag (iframe- and touch-safe, accessible). Collision detection is `pointerWithin` first (precise container drops across lanes), `closestCorners` as the keyboard fallback. Screen-reader announcements for drag start/over/end/cancel come from centralized strings and name the hovered column or card.
- **onDragStart:** records the active id in component state and in the store (`setDragActive`) so poll merges skip the dragged card.
- **onDragOver:** resolves the hovered target, a column/lane/sub drop id or a card, against the *current preview grouping*, then **preview-moves the dragged card into the hovered column and lane**, so the sortable gap shows the exact drop position. A change key (`column#index#lane`) suppresses redundant preview updates.
- **onDragEnd:** the final preview grouping *is* the placement. For split columns the Doing/Done sub comes from what the pointer is over at drop time; for constrained views (split sub-lists, lanes) the visible index is mapped onto the full column list via the constrained-list neighbors. A no-preview fallback path (e.g. keyboard drop) resolves the target directly. Dragging into *Unmapped* is disabled; a filter does not disable it (ADR-084): a card target places the card directly above the hovered card in the full list, a container target directly after the last card the user can see in that cell, and the store ranks against the full column, so hidden cards keep their places.

`boardStore.moveCard` then computes what actually changed, meaning state, column placement, split flag (leaving a split *column* resets the flag to false, even when the state did not change), lane field and rank, applies the optimistic update, marks the item pending, and submits **one** primary write with all changed fields plus any background renormalize writes.

**The placement write is the one new write of a multi-column board.** Column change and state change are different questions once several columns share a state: *Analysis → Development* changes nothing `System.State` can see, so the patch carries the column field alone: one rev-tested write, and no transition to gate or probe, because no transition happens. A cross-state move carries `System.State` (the target column's *state*, never its name) and the placement in the same patch. The value written is the column's name, so it reads as itself in WIQL. `moveCard` consults only `columnField` as the store holds it, and load sets that **only** when the layout is multi-column and the field is verifiably on the type, so a board whose columns are 1:1 cannot write the field even by accident, which matters because the field is organization-global and shared with the multi-column boards over the same type.

### In-column reorder (and list reorder)

Rank-only path: the new rank is the midpoint between the drop neighbors (`rankBetween`), over the eligible cards only. When the gap is exhausted (`isGapExhausted`), `respacePlan` respreads a ~20-item window inside the loaded span, a side with no loaded card beyond it being bounded by the window's own end card, which keeps its rank; it widens over loaded cards while the bounds are too tight. Only when the whole loaded column has no room does the store ask the service for the nearest item of the same project, of the respace's types, outside the board, one flat WIQL per open side, and `respaceWithin` spreads inside that gap, steps by the minimum past a lone bound, or refuses when both true neighbors leave no room (no rank written, a toast; an in-column drop goes back to the exact slot it left, a cross-column one keeps its move; bug 7303, ADR-087). Whichever tier planned it, the respace is then checked against what the board does not show: one flat WIQL for the items of the moved item's project and of the same type list strictly inside the span the plan touched (bug 7347, `respaceTypes`: the moved item's type, every type of the column's cards in that project, and when its order is a backlog's (`isBacklogRankProtected`, so a `K8` board) that level's types and, on the requirement level only, the Bug category, from `backlogOrderTypes`, whose failure refuses the respace as unchecked; one type renders as `=`, several as `IN`; closed items are read and are fixed points), at most 200 counting the cards the board shows, and a second plan confined between the same bounds with each of them as a fixed point. A settled plan whose span holds more than 200 is planned again from a window of three cards around the drop and read once more. No room around them refuses the respace the same way. A failed read refuses it too, with a toast saying the order could not be checked rather than that there is no room, and so does a throttled one, at once. The same toast answers when a card the plan would rewrite left the board, took another rank, or gained a write of its own while the reads were open, because a poll can land inside them: the whole plan is refused and the dropped card goes back where its rank puts it. An open plan whose span holds more than 200, or a narrow span still over 200, refuses with a toast saying there are too many items there, which does not invite a retry the same drop would fail again (bug 7307). The gesture is shown at once; the dragged item's write is the primary op, every other reassignment an individual background `renormalize` op, each independently rev-tested. A renormalize step lost to a conflict is simply dropped; the next poll re-sorts and the system converges. `reorderInList` is the same math over the flat query order.

Where the board orders by its field (`hasRankField`), the cards that hold no value in it are a tail the poll applies to every column and to the List: first the eligible cards whose value is empty, which the service sorts last under `ORDER BY [field] ASC` and the client sort puts last, then the ineligible cards by id. Every path that places a card applies the same tail (`rankTailSlot`): a card that will hold a value is placed ahead of the whole tail, so a drop, **Move to bottom** or **Move to position** below a card with an empty value draws the card where the next refresh keeps it, and its rank is read off the ranked neighbors as before. A card with no value of its own dropped below another card with none (`slotAmongEmptyRanks`) writes no rank, because any value would sort it ahead of the cards it was dropped below: inside one column, or in the List, the gesture is refused with an info toast; across columns the state and placement are still written and the card takes the slot its empty value gives it. `repositionItem` places a confirmed or re-read card by the same comparator, rank ascending with missing ranks last, on every board whose field is on, not only where the client sorts (bug 6928).

Both paths are gated by **`rankWriteBlocked`** (`src/domain/rank.ts`), which starts from **`rankReadOnly`** (K1). When the board's type is a requirement- or portfolio-level type of its subject project's process (`isBacklogItemType` over the cached process configuration; the subject is the project the board's definition names, else the host, `6589`), the backlog's order is **read, not written**, unless the board has turned on **Reorder from this board (writes the backlog's own order)** in its settings and confirmed it (K8). That opt-in is off by default and lifts the guard only for cards of the board's own type in the board's subject project: every `rankWriteBlocked` call is handed `boardSubjectProject(state)`, never the host, and the item's own project resolved by `orderProjectOf` with the host as the fallback, as eligibility resolves it (bug 7343: an empty `System.TeamProject` is the host's, never a project named ""), so on a board whose subject lives elsewhere the subject's cards are reorderable and a host card of the same type keeps its backlog's order (bug 7315). While the guard holds, `reorderInList` early-returns, `moveCard` drops the rank component of a cross-column drop, and the card menu replaces Move to top and Move to bottom with an explanation: *Reordering is off because the backlog owns this order* when the backlog's order governs the view (the client sort, or a query ordering by the rank field ascending), otherwise *The query controls order. AnyKanban never writes backlog order*, except that on a board holding K8's grant (the opt-in, one type on its level, not displaced) whose query controls the order, a card the opt-in reaches (`rankOptInReaches`, the function `rankWriteBlocked`'s carve-out asks) says *Reordering is off while the query sorts by …*, from `optInHeldBySort`, and an inline edit of its order field is refused with the same reason (`toast.querySortOn`) (bug 7579). A List row that K1 or the reader's view holds gives no reason of its own; its menu gives it. A flat query with no `ORDER BY` of its own is executed with the board's own order added (ADR-087), a tree source is sorted client-side by rank (ascending, missing ranks last, id tiebreak); an explicit `ORDER BY` keeps the server's order, and only the field ascending as its first key keeps reordering on. An unreadable process configuration derives no field: reordering off, one notice, never a guess.

### Poll merge: protecting pending and dragged items

1. Light poll: re-run the query, hydrate `System.Id`, `System.RelatedLinkCount` and `System.Parent` → ids, revs and link stamps, in one call per 200 ids. A link-only write revises neither end in Azure DevOps (`docs/SPIKES.md`, `6623`), so the rev cannot say a card's links changed; its link count and parent can. `data/linkTells.ts` diffs the stamps (first sight is a baseline, never a tell) and routes a moved one to the cards it concerns: the card itself, the old and new parent of a re-parented child, and every card holding a Related, Tested By or Dependency link to a far end whose count fell (a rise is the holder's own count moving, so it sends nobody else back; a far end's fall is what reveals a card that swapped one link for another in a single write). Those cards join the relations re-read below, so checklists, Related rows, dependencies, the rollup's tells, request order and a displaced board's population converge on the same tick. A failed re-read holds the stamp back so the next tick retries.
2. `diffSnapshot` computes added/removed/changed; **protected ids** (pending writes ∪ the actively dragged card) are excluded from fetching.
3. Only unprotected added/changed ids are hydrated with the board's field set.
4. Merge runs against the **latest** store state (writes may have settled during the awaits, so protection is re-checked): removals and fresh data skip still-protected items; the grouping is recomputed from query order, then each still-protected item is re-inserted at its current optimistic column/index. A poll can therefore never yank a card out from under the user or clobber an unconfirmed write.

### Inline add and inline edit

`addCard` parses the bound query's WIQL into field prefills so the new item matches the query. Where the board may write ranks, it assigns a rank past the current maximum. An item whose type has no rank field is created without one, and so is an item on a requirement or portfolio backlog unless the board has opted into writing backlog order. The item is born in the host project, so on a board whose subject project lives elsewhere it is born in a backlog the opt-in did not name and is created without a rank even with the opt-in on (bug 7315). It then creates the item in the board's type and inserts it optimistically. Whether the query actually returns the new id is then answered by the refresh the write already arms (ADR-079), not by a second run of the same query inside the gesture: the poll cycle judges every watched id against its own raw result, and raises an informational toast for one the query did not return, or a different one for an item it did return that falls past the board's 5000-item cap. `updateField` (click on a card field) is an optimistic single-field write through the same pipeline; identity fields use the native identity picker inside Azure DevOps (`IdentityPickerDropdown` backed by the host `PeoplePickerProvider`, committing `Display Name <address>`, the format the service resolves to an identity), and a plain text input in the standalone demo where no host identity service exists. Server rule rejections revert with the server's message.

### Dependency arrows

Relations require a second hydration pass (`getWorkItems` with `expand: Relations`, because the REST API rejects `fields` + `expand` together). `data/dependencies.ts` collects `System.LinkTypes.Dependency-Forward`/`-Reverse` links, normalizes both directions into `{pred, succ}` pairs, dedupes, and keeps only pairs where **both** ends are on the board. `ui/board/DependencyArrows.tsx` renders an SVG overlay (absolute, pointer-events none) over the board wrapper; paths are cubic beziers computed from the endpoints' live `getBoundingClientRect()` in the wrapper's viewport space, recomputed on capture-phase scroll and window resize, which sidesteps nested-scroller math entirely. Missing endpoints (filtered, collapsed, virtualized away) skip their arrow. Arrows hide during drag (`activeId` set). Red = successor's column index > predecessor's (column-order heuristic per ADR-016). Focus state (`dependencyFocus`, one card) and `showAllDependencies` live in the store; the card's link icon and the command-bar toggle drive them.

### Request order

A board of backlog items that names a request board (`requestBoardId`, ADR-092) derives, after its load and never on the poll timer, which request governs each card: `data/requestOrderRead.ts` reads the request board's shared definition (one settings read), checks that its query orders by its rank (its inline WIQL, or one read of its saved query's text), then `data/requestAncestry.ts` climbs from the cards' own relations (already in `relationsByItem`) through one `$expand=relations` batch per level above them over the distinct new parents, and reads the distinct Related targets once with the title, the type, the project and both platform order fields. The pure rule is `domain/requestOrder.ts`: the nearest ancestor-or-self with a request, the best rank there, and the marks as the complement of one longest non-decreasing run of keys in backlog order, which is the minimal set of moves. Ancestors and requests ride the poll's light id sweep with the revs recorded at derivation; a moved rev, a card's changed parent or Related set, or the five-minute safety interval derives again. A link added or removed from either end, on a card or above it, reaches the derivation within one refresh through the poll's link stamps (Poll merge, step 1, above), and so does one request swapped for another, since a watched end whose count falls sends its holders back. A link to an item nobody watches, swapped for another in a single write, reaches it with the five-minute read when it sits above the cards, and on a card only when the card next changes or the board reloads; the derivation records the stamps of the ancestors and requests it read, so the first tick compares against them. A failed re-derivation keeps the order shown, marked `refreshFailed`, and asks again at the safety cadence. The reader's By request rank view sorts what is shown and never the store's order, and blocks every rank write through `rankWriteBlocked(..., viewSorted)`.

### Take priorities

**Take priorities from {type}** (ADR-094) writes the backlog order from the request order, on every level from the subject project's top portfolio level down to the requirement level, after a preview. It is one overflow entry, offered by `domain/priorityOffer.ts` on a board whose request order is ready, and until `7296` sets its availability it exists only on a shared board whose `prioritiesWritable` is on. Every read is bought by the dialog, never at load or on the poll timer, and `data/priorityRead.ts` has no importer but the store:

1. **Open** (`openTakePriorities`). The board's cards, the cards the filter shows and whether a filter is on are frozen. The request board is resolved again (`resolveRequestBoard`), the write ladder read (`readWriteLadder`: the cached process configuration, the Bug category, the type fields and each type's state model), the other opted-in shared boards of the project checked for another request type (`readPriorityConflicts`, one Extension Data read), and the world read (`readPriorityWorld`): every card's relations with their fields in one batch per 200 ids, the climb above them, the walk below them to the requirement level, the distinct Related targets in one batch, and the requests judged. `domain/priorityScope.ts` builds the scope, `domain/priorityPlan.ts` the plan, and the span reads of `data/rankSpaceRead.ts` (one WIQL per level in the subject project, each type's closed states left out by name, paged by value and id up to 5,000 rows) fill what the plan's moves cross, at most eight rounds. `domain/priorityPreview.ts` turns all of it into the preview the panel renders. The store's slice is the only thing written.
2. **A choice** (`setTakePrioritiesChoice`): the movers come from the sets frozen at open, and only a choice that widens a level's travel reads its span.
3. **Apply** (`applyTakePriorities`): the board's own query again, then every read of the open again with the same movers and choices, and the plans compared. A difference shows the new plan and asks again. Then one `validateOnly` dry run per planned write, every level, before the first write, and then the writes level by level, top first, each level's span read again right before it and after any wait for a throttle, compared with what the run's own writes predict.
4. **Plan again** (`planTakePrioritiesAgain`) after a stop buys what Apply's recompute buys and keeps the movers, the choices and the items Azure DevOps refused.

The pass rule is the safety property: an item the run does not write is passed only toward request order when it has a key, freely when it has none, and never when its key or its links are unknown or it lies past what was read. The key is the item's own request in the first mode and its nearest request in **The whole hierarchy**, its own or else its nearest ancestor's, so there an item that inherits a request is compared by it. In **The whole hierarchy** a keyless item outside the run is passed only by a move up, so a move down past it is held. An item the reader kept and an item Azure DevOps refused in a dry run are the run's own and are not held to that: they are passed toward request order when they have a key, the mode's key, and freely when they have none. Ownership fails closed: a team board writes only items in its team's area paths, at every level, a team-less board only items in the areas its cards use, and an unreadable team scope writes nothing. The one widening is the reader's own: an area ticked under **Also reorder these** is owned, with everything under it, for that run on either kind of board, and is never stored (`ownsArea` in `domain/priorityScope.ts`).

**The whole hierarchy** (`7300`, the ADR-094 amendment of 2026-10-17) is the dialog's second mode. Both modes are planned at every plan, from one read, so switching between them reads nothing and the marks line can count from both. Its key is the nearest request, the item's own or else its nearest ancestor's, which is how the board's marks judge an item. Every level, top first, is planned toward a target order made against the final orders the plan gave the levels above (`cascadeLayout` in `domain/priorityPlan.ts`). An item whose request is its parent's, or that has none, follows its nearest ancestor with a place on a level above, in its current order among its siblings. An item with another request of its own, or one it takes from an ancestor with no place (in another project, closed, or on the item's own level), is placed by that request between the groups that follow the items above. An item whose parent sits two or more levels up follows that parent, after the items of the parent's own children, and an item that follows nobody and has no request goes last. The minimal-move planner of the first mode then writes the fewest items that reach that order, each write naming its reason. Only an item without a request of its own that the level's plan would move against the levels above as they stood when the dialog opened offers **Keep where it is** (`handPositionsOf`): below the top level, an item away from where its parent's place puts it among its siblings, and on the top level, or with no parent on a level above, a keyless item standing before items the target puts first. A kept item stays and is passed, the items under it follow it there, and the choice lives in the dialog only. Apply, its checks, its stops and **Plan again** are the same in both modes, and Apply writes the plan of the mode chosen.

### As-of snapshots

`setAsOf(date)` stores the timestamp and reloads: saved queries are first fetched as WIQL (`getQueryDetails`), then run via `queryByWiql` with a trailing `ASOF '<iso>'` clause (`withAsOf` replaces any existing trailing ASOF); hydration passes the same `asOf` to `getWorkItems`. Every mutating store action (`moveCard`, `reorderInList`, `addCard`, `updateField`) and `pollOnce` early-returns while `asOf` is set; the UI additionally disables drag, hides inline add, drops `onEditField`/`onWipEdit`, and shows a banner. Clearing the date reloads live using the remembered `lastOptions`. **As-of is not offered on a tree source** (`sourceKind === "tree"`): a link query rejects an `ASOF` clause outright, measured live as HTTP 400, so the command hides rather than failing (ADR-035; ADR-015 applies by not applying).

### Proactive transition gating

`data/transitionProbe.ts` sends one `updateWorkItem(..., validateOnly: true)` probe per target state, and for each one the server tells the current user whether that state change would save. Nothing is parsed and no extra scopes are needed. `probeTransitions(id)` (store) runs when a drag starts and when the context menu opens, and caches verdicts per `(id, rev)` in `transitionBlocks`. A target is marked blocked only when the rejection is a transition restriction: a rules rejection (`classifyWriteError === "rules"`) whose `System.State` entry carries the `invalidListValue` flag (`isTransitionRestriction`). A rejection for another reason, such as a required field left empty, counts as an answer but never dims a column; that move is left to the reactive snap-back with the server's message. Anything that is not an answer fails open to the same snap-back path (BDD-covered via a simulated probe outage). `BoardView` dims confirmed-blocked columns (`.qk-column-restricted`), gates `resolveHover` so the drag preview never enters them, and cancels the drag when the drop lands on one (otherwise the last allowed preview position would commit, a real bug the BDD suite caught). `CardMenu` filters the **Move to column** submenu with the same verdicts, on a card and on a List row alike. The rest of that menu (`E26`, ADR-046): **Move to iteration** reads the CARD's project's iteration tree from project-scoped classification nodes (`data/classificationNodes.ts`, cached per project+group, never the team-scoped `teamsettings/*`, and the module also serves the AREA tree behind the card's path editors) and writes `System.IterationPath` through the ordinary `updateField`; **Move to position** is a thin dialog over the same `moveCard` action, scoped by the shared `columnScopeMatcher` so the number it offers and the list the store ranks against are one thing (on a List row it counts the whole List and commits through `moveInList` instead); **Delete** calls `data/workItemDelete.ts` → `deleteWorkItem(id)`, recycle bin only, with `destroy` banned by `architecture.test.ts`, and the store restores its snapshot verbatim, `orderedIds` included, if the service refuses. Same-column moves (reorder, split sub-columns, lane changes) never probe, because they do not change `System.State`.

### Hub routing, switcher and the dashboard widget

Hub routing resolves once per load, in priority order: `?board=` host query param (HostNavigationService) → the board this user marked as default (`hub:<projectId>` user pref, `defaultBoardId`, ADR-050) → the user's last opened board (same document, `lastBoardId`) → the picker. Every route that reaches a board by its id opens it only for a caller it is listed to (ADR-098 decision 9), and the routes wait for different things on purpose. A `?board=` link waits for the whole listing (`readListingContext`), and so does an id that matches no board before it is refused, so the refusal takes as long either way and its timing does not say whether the board exists. The remembered default and last board wait for the one answer that decides that board (`readOpeningContext`): nothing for its author or the owner of a personal board, the root answer for a board that declares no team, and `getTeams` plus that team's area answer for one that does, through the listing's own memos. The two preference fields are read once, concurrently with the query params, because the picker's star and the board's own **Set as default** command need the stored choice even when a deep link decided the route. `openBoard` records the pref (debounced) and writes the query param so the browser URL stays shareable; "Copy link" builds an absolute URL via `getPageRoute` + `LocationService.routeUrl`, asked once when the hub mounts so that the copy itself runs inside the click (bug 6923): Chrome refuses `navigator.clipboard.writeText` in a cross-origin frame the host grants no `clipboard-write`, and the selection copy that `src/ui/common/copyText.ts` makes first is honoured only during the user's gesture. That the extension frame is refused the Clipboard API is inferred from the live reproductions rather than measured, because a failed URL build (`getHubPageUrl` answers undefined on any host error) reached the same fallback toast. The `@live` Copy link step in `c1-toolbar-settings.feature`, which asserts the success toast, is what confirms that the frame builds the URL and the copy lands. Every clipboard write in shipped code goes through that helper, which `architecture.test.ts` enforces. With 2+ definitions, `BoardApp` renders the header title as `BoardSwitcher` (custom dropdown; azure-devops-ui `Header.title` accepts a ReactNode). The dashboard widget (`src/entries/widget`, contribution `ms.vss-dashboards-web.widget` + configuration companion) reuses the data layer (saved query run, then a hydration of `System.State` plus the board's placement field when it has one, and a failure there retries with `System.State` alone rather than leaving the tile empty) and resolves each item's column with `columnOf`, the same rule the board uses, before aggregating via the pure `summarizeColumns`; clicking navigates the host to the board's deep link. The standalone harness renders the tile in a page of its own (`?mode=widget`), so the tile's read, its counts and its refresh are covered by BDD scenarios (`e2e/features/widget.feature`) as well as by unit tests of the aggregation. The dashboard host itself and the widget's configuration companion still need manual testing.

### Cross-project support

Hydration and updates omit the project route parameter, because work item ids are organization-unique, so `getWorkItems(ids)` and `updateWorkItem(patch, id)` reach items in any project (ADR-014). The host project is still used for state metadata, query trees, and inline-add creation.

### Config save

`ConfigPanel` edits a draft `BoardDefinition`; `saveBoardDefinition` writes the document with its `__etag`; on conflict the service re-reads, merges (local values win, fresh etag adopted), and retries up to 3 attempts. The hub reloads the definition list and the open board re-derives columns/rules; a changed query or type triggers a full reload.

## Concurrency model

### Per-item FIFO write pipeline

`WritePipeline` keeps a promise chain per work item id: writes to the same item serialize in submission order; writes to different items run in parallel. A failed op never blocks the chain (failures are swallowed after handling). Ops carry `kind: "primary"` (user gesture, with retry and toast semantics) or `"renormalize"` (background rank maintenance, dropped on conflict).

### Rev test on every write

Every update is a single JSON-Patch document that leads with a `test` operation on the path `/rev` against the rev the UI acted on (`buildUpdatePatch`), so a write can only succeed against the exact revision the user saw. There are no unguarded writes.

### Error classification: live-verified shapes

`classifyWriteError` maps harness errors, REST status codes, and service error payloads onto the four outcomes the UI distinguishes. The real-service shapes were **verified against live Azure DevOps**:

| Signal | Classified as | Verified behavior |
|---|---|---|
| The host-session lapse, checked before everything else: `HostTokenUnavailableError` / `HostUnresponsiveError` by name, or the service answering as its anonymous identity (`aaaaaaaa-…`) | `host` | The raw message is TF400813 blaming the reader for an outage (ADR-045); the toast substitutes the catalog sentence (`toast.writeHostFailure`), so the failure stays visible and the false cause claim does not. A real identity's 403 does not match and keeps its own message |
| HTTP **412 Precondition Failed**, `typeKey: TestPatchOperationFailedException`, message "VS403351: Test Operation for path /rev failed…" | `conflict` | The lost rev race: refetch and retry once (see below) |
| HTTP 409, or message matching `test operation` / `VS403351` | `conflict` | Defensive aliases for the same condition |
| HTTP **400**, or message matching `TF401320` / `rule` / `invalid state transition` | `rules` | Server-side rule rejection: revert the optimistic move and surface the server's message in a toast (snap-back UX) |
| Harness `RevTestFailedError` / `RuleViolationError` | `conflict` / `rules` | Mock-host equivalents |
| Anything else | `other` | Revert + toast |

### Conflict handling

On a **primary** conflict: refetch the item (pulling server truth into the store, which is also the revert path), then

- if the fresh state already equals the intended target state, someone else made the same move, so confirm as a no-op with nothing left to write;
- otherwise retry the operation **once** against the fresh rev; if the retry fails too, refetch again and fail with the retry error's classification and server message.

**Renormalize** conflicts refetch only, with no retry and no toast, and the next poll corrects the order. `confirmWrite` clears the pending flag, applies the confirmed fields, and, because a conflict-revert may have snapped the card back in the meantime, repositions the card immediately if its placement no longer matches the confirmed state. Every settled write (confirm or fail) triggers a fast follow-up poll (`pollSoon(1200)`).

### Plan steps (Take priorities)

A previewed bulk write takes a different path through the same pipeline, `WritePipeline.runPlanStep`, on its own instance (`getPlanPipeline()`), whose host is the dialog's world rather than the board's card store. It uses the same per-item queue mechanism on that instance, so its queue orders the plan's own steps only and never a gesture's. Three other things keep a gesture and a plan step from writing over each other: Apply does not start while any write of the board is pending (`pendingIds`), each item is held pending while its write is on the wire, and every step carries the rev test, so a gesture that landed first turns the step into a conflict. It passes the same `WEF_` guard and sends the same rev-tested patch, the unit's order field alone, with `validateOnly` as the typed client's fourth argument and nothing after it, so notifications are never suppressed and no rule is bypassed. There is no optimistic store move, no retry and no refetch: `classifyPlanStepError` answers one outcome per call (validated, written, conflict, refused for a rule or for permission, throttled with the instant to try again, or stopped with what the service said), and the run decides. A 412 on a dry run or a write stops the run. A refused dry run joins the refused set, and the plan is made again around it. A refused write joins it too, and stops the run. The run ends by asking the poller for a refresh through the same route every settled write takes, never by polling directly.

### Settings concurrency

Extension Data documents use the service's own `__etag` mechanism: `saveDoc` retries on `EtagConflictError` (detected from the error name or `InvalidDocumentVersion`-style messages) with a merge callback, max 3 attempts. Board definitions merge local-wins; user prefs merge fresh-under-local.

## Rate limiting and backpressure

Azure DevOps meters each **user** against a consumption budget in a rolling
five-minute window. Exceeding it returns **HTTP 429** with a `Retry-After`
header (seconds, or an HTTP date); continuing to send while throttled extends
the penalty. Because a board is a long-lived page that refreshes on a timer and
several people on one team may have the same board open, this is a realistic
failure mode rather than a theoretical one, and its symptom is the worst kind:
an apparently broken tool (stale or empty board, a burst of unhelpful error
toasts) rather than an explicit "you are being throttled".

**Implemented, as a central throttle gate** (backlog **G2**, phase 1;
`src/platform/throttle.ts`; the decision is ADR-022):

- **One choke point for the typed clients.** Every call on the typed REST
  clients in `platform/clients.ts` (work item tracking, work, core, feature
  management and member entitlements), reads *and* writes, passes through a
  gate wrapped around the client accessors (a proxy applied once per client
  and to harness overrides alike), so a data module that uses those clients
  cannot opt out. The Analytics reads, the process API and, since the review
  of `7449`, the raw permission reads in `data/callerPermissions.ts` call the
  gate themselves. A permission read is also bounded at 15 seconds
  (`PERMISSION_READ_TIMEOUT_MS`, against the typed reads' 60, ADR-080), and a
  refused or timed-out one is no answer, which lists the board and offers the
  control. Both bounds are per request, never per question: the team-area
  question chains the team list and the team's area paths (typed reads) with
  the area tree, the namespace bit and the permission chunks (raw reads), in
  sequence, so no question has a total wait of either number. One path does
  not pass through the gate: the Extension Data Service reads and writes,
  which go through the host's own data service.
- **On 429:** parse `Retry-After` (both encodings, delta-seconds and HTTP
  date) and set a single global *paused-until* timestamp. Every outbound call
  is held or refused until it passes. One shared state, not per-caller backoff.
  ⚠️ The typed clients drop response headers when a request fails, and the error
  carries only `status`/`responseText`, so the header is captured by a
  **passive `fetch` observer** installed with the real clients; the error path
  falls back to an exponential pause when no header was seen. (`Retry-After`
  is also not CORS-safelisted, so on a cross-origin host that does not expose
  it the fallback is the normal path, degraded but correct.)
- **Reads are skipped, never queued.** A read issued while the gate is closed
  is refused with `ThrottledError`; the poller treats that as a skipped cycle
  (not a failure) and schedules the next tick past the pause. A missed refresh
  is repaired by the next one, whereas a queue of stale refreshes would
  stampede the moment the window opens. The one deliberate exception: a
  **user-initiated board load** waits the window out and retries (bounded), because
  a gesture must not present an empty board as broken.
- **Writes are queued, never dropped.** `runWrite` waits until the window
  expires and retries a mid-flight 429; the `test` on `/rev` still guarantees
  correctness on resume, and a change made by someone else in the meantime
  surfaces as an ordinary conflict, not as a lost update.
- **One discreet indicator, not a toast storm.** The board renders a single
  warning banner ("Updates are paused because Azure DevOps is temporarily
  limiting requests. Wait until {time} for refreshing to resume.", with the
  time in the browser's local format) from the gate's subscription
  (`useThrottleNotice` in `BoardApp`) instead of a toast per failed call.
- **React before being blocked.** A `Retry-After` on a *successful* response is
  the service's pre-throttle delay hint; the observer records it the same way,
  so the pause starts before the first 429 arrives.

Not built: adaptive cadence from the remaining-budget
headers, stretching the poll interval when a cycle starts costing more than a
few calls.

**Testability.** The gate takes an injected clock and sleep
(`src/__tests__/throttle.test.ts` covers both `Retry-After` encodings, the
skip/queue split and the fallback), and the harness answers with a live-shaped
429 + `Retry-After` (`throttleNextRequests`), so the BDD suite asserts the
contract end to end: the board pauses instead of erroring, one notice appears
and clears, and the load completes on its own.

## Settings storage

### BoardDefinition schema (current shape)

One document per named board, collection `boards`, in the shared (`Default`) or the personal (`User`) scope, `schemaVersion: 1` (migrations gate on this):

```ts
interface BoardDefinition {
  schemaVersion: 1;
  id: string;                       // document id (UUID)
  name: string;                     // display name in the Boards hub
  projectId: string;                // boards are listed per project
  queryId: string;                  // the saved query the board runs: flat,
                                    // tree or direct-links; link results are
                                    // flattened to their distinct targets
                                    // (ADR-035). Empty exactly when `wiql`
                                    // carries the query: a definition holds one
                                    // or the other, never both. A flat query
                                    // may ORDER BY the process's order field
                                    // ascending, or carry no ORDER BY
  wiql?: string;                    // the board's own query text, for a board
                                    // created from a template (J5); nothing is
                                    // written into the customer's Queries
  subtreeRoots?: number[];          // the root items a subtree board was built
                                    // from (J1); advisory, for the settings
                                    // panel only. `wiql` is what runs
  workItemType: string;             // the board's anchor: the level the query
                                    // returns; on a tree source it is the
                                    // boarded level (ADR-035). The toolbar's
                                    // Level selector never rewrites it (ADR-063)
  boardedLevelType?: string;        // the level the board displays when that is
                                    // not the anchor's (ADR-063); absent means
                                    // the anchor's own level
  levelIncludesChildless?: boolean; // on a board displaced upward, also show
                                    // items of that level with no children
  rankField?: string;               // deprecated (ADR-087): parsed and ignored; the
                                    // order field is derived from the process at load
  rankWritable?: boolean;           // K8 opt-in: let this board write the
                                    // backlog's own order for its own type in
                                    // its own project; default OFF, turned on
                                    // only through a confirmation
  cardFields: string[];             // field reference names rendered on cards
  listColumns?: string[];           // the List's columns, in order (ADR-088);
                                    // absent = ID, Type, Title, State, Tags
  styleRules: StyleRule[];          // ordered; AND-ed clauses; @Today/@Me macros
  tagColors: Record<string, string>;
  wipLimits: Record<string, number>;    // keyed by COLUMN NAME (= the state name
                                        // on a board with derived columns)
  columnOrder: string[];                // vestigial: column order is the order of
                                        // `columns`; nothing reads this
  dodTexts: Record<string, string>;     // Definition of done, keyed by column name
  columns?: ColumnDefinition[];     // ADR-033: the board's own columns, in board
                                    // order. ABSENT on a normal board, and then
                                    // one column per state is derived at load,
                                    // exactly as before the field existed
  columnField?: string;             // string field recording WHICH of several
                                    // columns over one state a card sits in
                                    // (Custom.BoardColumn by convention); never
                                    // written while the columns are 1:1
  splitDoneField?: string;          // boolean field backing Doing/Done split;
                                    // split unavailable if unset (ADR-006)
  splitColumns?: string[];          // column names rendered split Doing | Done
  laneField?: string;               // field whose values form swimlanes (simple
                                    // mode); lanes off if unset (opt-in, ADR-007)
  lanes?: LaneDefinition[];         // criteria-based swimlanes: first lane whose
                                    // AND-ed clauses match wins; takes precedence
                                    // over laneField when non-empty
  laneNode?: LaneNodeConfig;        // lanes generated from a tree node: field
                                    // (Iteration Path or Area Path), node path,
                                    // the unplanned lane, order, show empty
  laneTotals?: LaneTotals;          // up to two numeric fields summed per lane and
                                    // per cell, with an optional capacity per lane
                                    // name; absent means counts only (ADR-085)
  hideEmptyFields?: boolean;        // hide valueless fields on cards (default false)
  showCardId?: boolean;             // work item ID on cards (default true;
                                    // native "Show ID")
  showAging?: boolean;              // I2: days-in-current-state pill (default
                                    // true); degrades to a named warning when
                                    // the type lacks the state-change field
  agingThresholdDays?: number;      // I4: absent = compare within the column;
                                    // a number is an absolute threshold in days
  nativeTransitionParity?: boolean; // parse-only relic, removed by ADR-058;
                                    // nothing reads it, gating is always on
                                    // (fail-open when the probe cannot answer)
  checklistTasks?: boolean;         // E2: child-task checklist (default true)
  checklistBugs?: boolean;          // E2: child-bug section; seeded at board
                                    // creation from the organization default,
                                    // else the DEFAULT team's bugsBehavior
                                    // advisory (a team-less read), stored
                                    // explicitly (E11)
  checklistTests?: boolean;         // E3: Tests chip (Tested By links),
                                    // deterministic order; treated as OFF when
                                    // absent; a new board proposes ON when it
                                    // boards backlog items or bugs
  relatedSection?: boolean;         // E4: Related chip and its rollup; when
                                    // absent the board kind decides: ON unless
                                    // the board boards backlog items. A stored
                                    // value always wins
  subjectProject?: string;          // the project whose process judges the
                                    // board's own type; absent = the project
                                    // the board is opened from
  teamId?: string;                  // the team the board declares (ADR-074):
                                    // whose scope and backlog settings it reads
  listUnderTeam?: boolean;          // whether a shared board is also listed in
                                    // that team's group; absent = listed there
  teamBoard?: { boardId: string; boardName: string };
                                    // take columns and lanes from that team's
                                    // own board; only the ids are stored, the
                                    // layout is read at load
  filedBy?: string;                 // the user id of whoever created (or
                                    // imported) the board: it is always
                                    // listed for that person, and that person
                                    // may change it (ADR-098); absent =
                                    // administrators only
  filedByName?: string;             // that person's display name, stamped
                                    // with filedBy or on their next save;
                                    // rendered in the read-only sentence,
                                    // never compared
  folder?: string;                  // one-level folder label in the board list
  requestBoardId?: string;          // 7297, ADR-092: a shared request board
                                    // this board of backlog items compares its
                                    // order with; the request type and its
                                    // order field are derived, never stored
  prioritiesWritable?: boolean;     // 7299, ADR-094: let Take priorities
                                    // write the backlog's order from that
                                    // request board; stored only when on,
                                    // never seeded, dropped on import and on
                                    // a move to a personal list, in force on
                                    // a shared board only
  // Both of the next two are DEPRECATED since ADR-057 and nothing reads them.
  // They stay declared so a board stored before that date still parses, and
  // an ignored key is a better outcome than a document that fails validation.
  mixedLevels?: boolean;            // was K5 (ADR-036). A board now admits ONE
                                    // level's types, and the level carries its
                                    // own set, which is why Bug beside PBI
                                    // needs no setting at all
  childrenSection?: boolean;        // was K10 (ADR-037). The section is DERIVED:
                                    // a backlog subject shows it unless it is
                                    // the lowest level, anything else shows it
                                    // only for a tree query. Its rows carry a
                                    // CHECKBOX since E2 (ADR-037 amendment),
                                    // backlog-rank order; portfolio cards add
                                    // an own-subtree rollup
}

interface ColumnDefinition {
  name: string;                     // board-unique: the label, the key every
                                    // per-column setting is stored under, and the
                                    // value written into `columnField`
  state: string;                    // the state a card must be in to sit here;
                                    // colour and category are read from the live
                                    // state model at load, never stored here
}
```

**Columns are configuration, not the state model** (ADR-033). Several entries
may name the same state, with `Analysis` and `Development` both over `Active`,
and `columnField` then records which of them a card is in. The state remains the
only truth: it selects that state's candidate columns, the field chooses among
them, and the **first is the fallback** for a value that is empty, names another
state's column, or names a column that has been removed. That ordering is what
makes editing the list non-destructive; renaming, reordering or deleting a
column rewrites no work items. A board whose columns are 1:1 with states needs
no field and never writes one, and a multi-column layout whose field is missing
from the type falls back to derived columns with a warning naming the field: whole,
never half-working.

Hand-writing a definition for import is therefore possible but constrained:
`saveBoardDefinition` rejects a layout that cannot mean anything (duplicate or
empty column names, a column without a state, the `__unmapped__` sentinel or a
control character in a name, two columns over one state with no `columnField`,
and one field bound to two jobs) with a `ColumnLayoutError`, and reserved field
names, `WEF_*` above all, with a `ReservedFieldError`. Import stores neither, and reports how
many boards it rejected together with the distinct reasons, not which board
failed, because five boards rejected for one reason should read as one problem.

Checklist data itself is **never** stored here: children, their states and
their order live on the work items (state + the process's ordering field) and
are re-read each load through the shared relations pass, and the extension
stores no per-card document of its own (E8's simplification).

### Scopes

| Data | Collection | Scope | Notes |
|---|---|---|---|
| Board definitions | `boards` | `Default` (shared) or `User` (personal) | A shared board is listed in that project's hub: a board that declares a team for those who may read work items in at least one of that team's own area paths (asked per area node with its chained token, never its descendants), a project board that declares none for those who may read work items at the project's root, and always for whoever created it. Changes to a shared board are offered to its creator, to holders of **Edit project-level information** in the project and, for a board filed under a team, to those Azure DevOps lets change that team's board (`mayEditBoard`, ADR-098). A board with no recorded creator is changed by administrators only. A personal board (**My board list**) is stored in its creator's `User` scope and is listed for nobody else. Multiple named boards per project. The template gallery asks **Where should this board appear?** and starts from **My board list** unless a team was already chosen. The Extension Data Service has **no permission model for shared documents**, so any extension user can technically write them, and the product's gate is a guard against accidental change, never access control. |
| User preferences | `prefs` | `User` | View settings, and one filter per saved query: per board the view mode, collapsed columns and lanes, list column widths, rollup view, silenced notices and the By request rank view; per saved query (`query:<id>`) the filter its page was left with (`queryFilter`: the keyword, and the states, assignees' display names, tags and field values chosen), written only on the query page and kept until cleared (ADR-100); per project hub `lastBoardId`, `defaultBoardId` and which board-picker groups are open; for the aging chart whether its first-run explanation was dismissed and whether it draws percentile lines. Private per user; debounced, loss-tolerant. The default board is deliberately here rather than in the shared definition (ADR-050): which board opens is where a person *is*, not what a board *is*. |
| License record | `license` | `Default` (shared) | The signed key an administrator records. Verified offline against a public key compiled into the extension. The user count the **Count users** button reads is deliberately **not** a document here or anywhere else: it lives in the page until the tab closes, and goes only into the buy link it prefills, where the buyer can still edit it. The count is an aid to a correct self-declaration, and the declaration stays the buyer's. |
| Usage counters | `usage`, document `counters` | `Default` (shared) | The enumerated aggregate counters of ADR-060: numbers only, never a work item, title, query or person. Exportable as JSON, the whole enumerated list including the counters at zero, and switchable off and deletable on the organization settings page. |
| Organization defaults | `boardDefaults`, document `current` | `Default` (shared) | The starting values a new board is proposed with (ADR-061). Read on hub load; changing them never reaches a board that already exists. |
| Field reservations | `fieldPairs`, documents `allocations` and `createdFields` | `Default` (shared) | Which board reserved which of the fields the field setup provisions (a board id and a number per entry), so two boards never record card placement in the same field (ADR-075). `createdFields` lists the reference names of the fields AnyKanban itself created, so the setup never writes through a field that is not its own. No board name, query, project, person or work item content. |
| Board baselines | `baselines`, one document per board key | `Default` (shared) | What each lane held when somebody pressed **Set baseline** (`6585`): per lane the moment, the name and id of the person, and the id, title and lane-field value of every item. The one stored document that carries work item titles, kept so an item that has left the board can still be named. Read when the board key changes and on every load, never on the poll timer, and once more right before a set, a replace or a clear over a baseline the board has already read; written on **Set baseline**, replaced on a new set, rewritten without that lane's entry on a lane's **Clear** from its header (clearing the last lane leaves an empty document with no work item content, which the board reads as no baseline), deleted on the whole board's **Clear baseline**, and deleted with its board in the hub. Each lane entry also records the lane mode and the type the board showed, and only entries whose both still match are compared; a whole-board set records the same two for its coverage of lanes that appear later, and a cleared lane is listed so that only it stops being compared. Read once per load beside the rest of the board, and compared at render with the loaded items, so comparing with a stored baseline costs no work item request. The baseline from the iteration start (`7298`) is NOT in this document and stores nothing: the board replays its own query as of the iteration's start plus the offset, one query and one work item read per period in progress, with every load, plus the saved query's text where the board does not already hold it, the project's iteration tree where the query names an iteration (cached for the life of the tab) and, on a board whose query says `@CurrentIteration`, one read of its cards' iteration paths where the board did not already load them; an explicit entry in this document wins for its lane. |
| Evaluation start | `evaluation`, document `start` | `Default` (shared) | One date: the first day AnyKanban ran in the organization (ADR-077). Written once and never rewritten, so the organization settings page can show how much of the 60-day evaluation is left. No user, board, work item or time of day. |

### Export / import

Extension data does not migrate across organizations, so the hub command bar offers the escape hatch: **Export** downloads the boards in your own board list, the shared boards listed for you and your personal ones, as `query-kanban-boards.json`, each entry recording whether it is shared or personal; when boards outside your list were left out, a message says how many. **Import** reads such a JSON file and re-saves each valid entry (`schemaVersion === 1`, a name, and either a query id or the board's own query text) under a **fresh document id** and the current project id, so imports never overwrite existing boards. A personal entry comes back personal, and a team id that names no team in the target project is dropped, so that board lands unfiled. Query ids and field reference names must exist in the target organization for an imported board to work.

## Testing

### Unit tests: Jest 29 + ts-jest (jsdom, @testing-library/react 12)

`src/__tests__/` covers the pure core: `rank` (midpoint, gap exhaustion, renormalization windows), `styleRules` (operators, `@Today`/`@Me` macros), `diff`, `writePipeline` (per-item serialization, conflict retry, rules revert against a mock client), `queryTabContext` (payload shape variants), `hydrator` (chunking), `cardMapper`, `grouping`, `filtering`, and `wiqlPrefill`. The SDK ships AMD and the api package imports it at load time, and both are unparseable in Jest, so `jest.config.js` maps `azure-devops-extension-sdk` and `azure-devops-extension-api` to stubs in `src/testUtils/`.

### BDD end-to-end: Playwright + playwright-bdd against the mock-host harness

The `.feature` files under `e2e/features/` (steps in `e2e/steps/`, generated by `bddgen`) run against the **standalone entry** (`src/entries/standalone/`): the full real UI with all host seams overridden, meaning `setSdkOverride`, `setWitClientOverride`, `setOpenWorkItemFormOverride` and `setSettingsBackendOverride`, backed by `harness/memoryStore.ts`, an in-memory work item store that implements **rev semantics** (rev increments on update; a stale rev test throws `RevTestFailedError`) and **rule simulation** (a scenario hook can reject specific transitions). Tests are fast, deterministic, and login-free. For the BDD suite, the default renders a fixed board, and `?mode=hub`, `?mode=admin` and `?mode=widget` render the hub, the organization settings page and the dashboard widget. The same entry also serves the public **demo** on the website at `/demo/` (that path implies `?mode=demo`), which loads the demo presets over a settings backend that writes nowhere.

`installMemoryHost` exposes the harness to tests as `window.__qkHarness`:

| API | Purpose |
|---|---|
| `reset(items)` | Reseed the backend (clearing rules) and reload the board |
| `configure(patch)` | Patch board load options (e.g. `laneField`) and reload |
| `setForbiddenState(state, message?)` | Simulate a process rule rejecting transitions into a state |
| `bump(id)` | Simulate another user editing the item (bumps rev behind the UI) |
| `getBackendItem(id)` / `getBackendField(id, ref)` | Assert server truth (state, rank, rev, any field) |
| `backendOrder(state)` / `queryOrder()` | Assert persisted rank order |
| `findByTitle(title)` | Locate items created through the UI |
| `openedWorkItems` / `store` | Native-form-open assertions; raw store access |

Feature coverage, one file per area (`board`, `dnd`, `config`, `styling`, `lanes`, `columns`, `checklist`, `related`, `deps`, `aging`, `contrast`, `paging`, `iteration-macro`): board rendering from the state model incl. Unmapped and hidden Removed-state items with the count note, keyword filter, list view in query order, opening the native form, inline add, DnD state change, in-column and list reorder persisting backend rank order, forbidden-transition snap-back with the server message, concurrent-change retry without losing the move, split columns writing the bound boolean field, field-derived lanes writing the lane field, inline field edit, style rule accents, WIP exceeded counts, DoD hints, and named-board config CRUD surviving reload; configured columns over one state, where a same-state move writes the placement field and leaves the state alone, a cross-state move writes both in one go, and a layout without its field falls back to states and says why; card checklists (category-mapped checkboxes, rank-ordered task drags, keyboard reordering, rejected child writes reverting with the server's message); the Related section and its rollup, incl. lower-bound counts and the flyout the numbers came from; dependency arrows and the read-only as-of snapshot; the aging pill and mode on a frozen clock; the card under `forced-colors: active`; paging in a large column and in the list; and a query naming a team with no scheduled sprint, which is named rather than reported as a failed load. DnD is driven by real pointer-event sequences (`e2e/steps/helpers.ts` exceeds the 4 px activation distance before moving to the target) because dnd-kit does not react to synthetic HTML5 drag events.

Playwright's `webServer` starts `npm run dev` and waits on the standalone page; `ignoreHTTPSErrors` accepts the self-signed dev certificate.

### Operational notes: dev-server pitfalls (learned the hard way)

- **Error overlay iframe steals pointer events.** webpack-dev-server's client error overlay is a full-viewport iframe; when it appears it silently swallows the pointer sequences that drive dnd-kit, making drags dead-end. It is disabled (`client.overlay: false`).
- **Hot/live reload off.** `hot: false, liveReload: false`, because a mid-session page reload wipes iframe state inside Azure DevOps and races Playwright runs. Code changes are picked up on manual F5.
- **`content-visibility` vs `MeasuringStrategy.Always`.** `content-visibility: auto` on cards is tempting for large boards, but it fights dnd-kit's `MeasuringStrategy.Always`: sizes change on every measure, producing an infinite re-measure loop and React's "maximum update depth" error. Do not add it to `.qk-card` (see the note in `board.scss`).
- **`devMiddleware.publicPath` is `/dist/`.** Contribution uris are `dist/<entry>/<entry>.html` and the dev overlay's `baseUri` is the server origin, so the host requests `/dist/...`, and the compiled output must be served under that prefix.

### Live: a real organization

The BDD scenarios tagged `@live` run a second time against a dedicated test organization (real iframe handshake, real REST), through `npm run e2e:live`. The test user signs in once and the session is reused as a Playwright `storageState`, so that sign-in has to finish with nobody at the keyboard. Where the tenant asks the test user for a second factor, the sign-in answers with a code from an authenticator app secret kept with the test credentials; where it cannot answer, it stops and names the page that asked. A pipeline of its own (`azure-pipelines-live.yml`) runs the live suite every night on our own build agent, and it can also be run on demand.

## Build and release

### Dev loop

```bash
npm install
npm run dev        # webpack-dev-server on https://localhost:44300 (self-signed)
```

Accept the certificate once at `https://localhost:44300` (or install a locally-trusted cert via mkcert), otherwise the extension iframe fails silently. Publish the **dev extension** once (`npm run package:dev`). After that, code changes are picked up on a manual browser refresh with **no republish**; republish only when the manifest itself changes.

There is no overrides mechanism and no `manifests/` folder: **two complete manifests sit in the repo root**, `vss-extension.json` and `vss-extension.dev.json`. They are identical except for the extension id, the display names (`(Dev)` suffix) and the dev manifest's `baseUri`, which points at the local dev server. Both live in the root because `tfx` resolves `files` paths relative to the manifest's own folder, not `--root`.

### Packaging and publishing

| Command | Result |
|---|---|
| `npm run build` | Production webpack build to `dist/` |
| `npm run package:dev` | Build + `tfx extension create` with `vss-extension.dev.json` → `out/` |
| `npm run package:prod` | Build + **`verify:package`**: packages the production manifest and audits the result → `out/verify/` |
| `npm run verify:package` | The audit alone (also runs in CI after the build) |
| `npm test` / `npm run e2e` / `npm run e2e:headed` | Jest units / BDD e2e (`bddgen` + Playwright) |

**Production is published with `--vsix`, never `--manifest-globs`.** The latter re-packages from source, so the shipped bytes would not be the audited bytes. `azure-pipelines.yml` enforces this: the audited package travels between stages as an artifact.

The `publisher` in `vss-extension.json` must match the Marketplace publisher account. Organizations install the extension from the Visual Studio Marketplace; a Project Collection Administrator installs it once for the whole organization.

### CI

There are two pipelines, and every run of either starts with the same Verify stage, on Node 20: `npm ci`, the typecheck, Jest with `--ci`, the Playwright BDD suite, then `npm run package:prod`, which builds the extension and audits the package. Test results and the Cucumber report of the BDD run are published on every run, and the Playwright report when the run fails. The Verify stage never publishes the extension.

`azure-pipelines-ci.yml` runs on every push to a working branch and on every pull request, and on `main` it also deploys the website and the licensing service. Its trigger is batched: pushes that arrive while a run is in progress are built together, once, when it finishes. `azure-pipelines.yml` is the release pipeline. It runs only for the `release` branch, which is fast-forwarded from `main` and never committed to, once for every push, and it publishes the package its own Verify stage audited in the same run. A pull request runs Verify and nothing after it. The release credentials are bound to the delivery stages alone, so verification runs without them.

**Every stage is a template under `pipelines/`**, and each root pipeline file declares the triggers, parameters and variables and then references those templates. That is what lets a second pipeline reuse a stage without copying a line, and it already happens three times: the CI pipeline, the release pipeline and the nightly live run (`azure-pipelines-live.yml`) all consume the same `verify-stage.yml`, so the guarantees a stage carries stay defined once rather than duplicated and drifting.

### How a version reaches an organization

A release is not one event. The bytes reach the Marketplace, and then each
organization's installation has to move to the new version. Either step can
succeed while the next one does not, so each is checked separately.

**The audited package is the shipped package.** The Verify stage builds and
audits the production VSIX once, and every later stage publishes that file
instead of rebuilding it. A package for an early-access audience is derived
from the same build and differs from the audited one only in its manifests,
which `scripts/verify-ring-packages.mjs` proves entry by entry, by name, size
and CRC.

**Releases are staged.** A new version runs in our own organizations first,
then reaches a small group of organizations that asked for early versions, and
only then the Marketplace listing. Each stage has entry and exit criteria, held
as named functions in `scripts/ring-gate.mjs` rather than in YAML. A criterion
that answers "not yet" is the system working. A criterion that is configured to
run but **cannot be evaluated** fails the run, because silence there would
promote on ignorance. Some criteria are still named placeholders, and a check
that needs a token nobody supplied passes too; both say in the log that they
did not look.

**Three independent facts are compared:**

| Fact | Source | What it proves |
|---|---|---|
| the manifest version | `vss-extension.json` on `release` | what this branch intends to ship |
| the gallery version | Marketplace REST | what is on the shelf |
| the installed version | each organization's `extensionmanagement` API | what people actually run |

They are checked inside the pipeline, as exit criteria of the release stages,
and outside it, as `npm run verify:deployed`, which needs no pipeline to have
run. The installed version is read only for an organization whose token the check
holds. A customer's organization that has given us no token is logged as not
checked and blocks nothing.
The outside check is there because a check inside a pipeline cannot report on a
pipeline that never started.

## Platform limits

| Limit | Consequence in AnyKanban |
|---|---|
| WIQL results hard-capped at **20,000 items** (REST error **VS402337**) | `queryRunner` maps the overflow to a typed error with a clear "narrow the query" message; the board cannot load past the cap |
| **≤ 200 ids per Get Work Items call** | `hydrator` chunked batching; poll re-hydrates changed ids only |
| **Data soft cap `SOFT_CAP = 5000`** | Results beyond it are truncated with a visible warning. This bounds hydration and poll cost only, because rendering is bounded separately by per-view paging (200 per column/list with "Show more"), see ADR-021 |
| **No push channel for extensions.** SignalR is internal to the host; `IWorkItemNotificationListener` fires only inside the work item form | Freshness is polling by design (ADR-004): light id+rev poll every 20 s visible / 120 s hidden, jitter, exponential failure backoff, forced poll on focus and ~1.2 s after own writes. True real-time parity is not achievable client-side |
| **React 16.14 pin**, required by `azure-devops-ui` 2.276.0 | All dependencies chosen React-16-compatible (dnd-kit, zustand, RTL 12), exact versions pinned; the SDK is isolated behind `platform/sdk.ts` so a future upgrade is a one-module change |
| **Undocumented query-tab context** | Defensive parser + picker fallback; the hub never depends on it |
| **Service rate limiting** (per user, 5-minute rolling window; 429 + `Retry-After`) | Central throttle gate (`src/platform/throttle.ts`, backlog G2): one global paused-until, `Retry-After` honored in both encodings, reads skipped / writes queued, one banner, on top of jittered light polls, hidden-tab slowdown and changed-only hydration |
