AnyKanban

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. 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:

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

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):

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):

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)

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

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