Skip to main content

FOLIO-UNIFY story-contract gate

Card: AB#13555 (FOLIO-UNIFY QA-52) · Measured: 2026-09-06

Three FOLIO-UNIFY cards in a row shipped against empty acceptance criteria with no story record. The title was a lower bound. This page records what was measured, the check that now makes it fail, and the one question only an operator can settle.

What was measured​

Every number below came from the command beneath it. None is quoted from a card.

# The corpus: every work item tagged FOLIO-UNIFY.
az boards query --org https://dev.azure.com/CentraLogicIndia --project KeyNova \
--wiql "SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS 'FOLIO-UNIFY'"

# The check, over a live read.
npx --prefix tools/registry tsx tools/registry/src/cli.ts \
check-story-contract --tag FOLIO-UNIFY --strict

Output of that check on 2026-09-06:

Read 233 work items tagged "FOLIO-UNIFY" from https://dev.azure.com/CentraLogicIndia/KeyNova.
scanned 233 card(s).
no acceptance criteria : 226/233
no story link : 229/233

0 of 233 cards satisfied both limbs. The card's "three in a row" understated the rate by two orders of magnitude.

Split by state (System.State), so the fixable half is separable from the historic half:

StateCardsViolating
Closed-Verified5656
In-Review2020
Proposed157157

Correction: the AcceptanceCriteria field does not exist on Task or Bug​

AB#13555's description says these cards' Microsoft.VSTS.Common.AcceptanceCriteria field "is EMPTY". Measured, that is a category error for 222 of the 233 cards. The field is not on the work-item type at all:

az rest --method get --resource 499b84ac-1321-427f-aa17-267ca6975798 \
--url 'https://dev.azure.com/CentraLogicIndia/KeyNova/_apis/wit/workitemtypes/Task/fields?api-version=7.0'

AcceptanceCriteria present on type — Task: false · Bug: false · User Story: true. The corpus is 189 Task + 33 Bug + 11 User Story.

This matters for gate design. A check that demanded that field on a Task would be unsatisfiable — nobody could ever turn it green, so it would be switched off. The check therefore accepts an "Acceptance Criteria" section in the description or in a comment as the contract for types that cannot carry the field. Seven cards (AB#13566, 13568–13573) already do exactly that and pass the AC limb today.

Likewise, "no linked story" is true for all 233, but the related claim that several cards have relations: null is only true for some: 220 of the relations across the corpus are ArtifactLink (commit / PR / build). An ArtifactLink is evidence of a diff, never of a ratified contract, so the check does not accept one as a story link.

What the gate checks — and what it cannot​

It gates presence:

  • MISSING-AC — no non-empty Microsoft.VSTS.Common.AcceptanceCriteria, and no "Acceptance Criteria" / "AC:" section carrying actual content in the description or any comment. A bare heading with nothing under it is refused.
  • MISSING-STORY-LINK — no System.LinkTypes.* work-item link, and no story-record path (a docs/**.md path) cited in the description or comments. A bare CLAUDE.md mention is not a story record.

It cannot gate quality. Whether the acceptance criteria is testable, matches the domain, or covers the right grain is a hospitality-ba / hospitality-sme / story-verifier judgement. A card can satisfy this gate with one useless sentence. That limit is deliberate and is written into the module doc-comment; do not extend the checker with a quality heuristic.

Two further limits, stated rather than hidden:

  • The story-link limb checks that a path is cited, not that it resolves. Paths point into any of the platform's repos, which is not checkable from hmp-docs alone. (AB#13547 cites docs/claude/folio-events-consumed.md, which does not exist in this repo.)
  • The corpus moves under the gate. AB#13533 gained a story link between two reads taken ~25 minutes apart in the same session. The waiver list is a dated snapshot and the STALE-BASELINE rule is what stops it rotting.

The waiver, and why this lane is red today​

registry/story-contract-baseline.json waives the 76 cards already Closed-Verified or In-Review on 2026-09-06 — those cannot be retro-contracted without an operator ruling, so the debt is recorded explicitly and citably instead of being left as a silent absence.

The 157 Proposed cards are deliberately not waived. They have not shipped, so they can still gain a contract. .azuredevops/pipelines/story-contract-gate.yml would therefore be red today, on purpose, and it carries a daily cron rather than gating unrelated diffs. Turning it green means writing those contracts — not widening the waiver.

⚠ Measured 2026-09-07: that lane is not registered with Azure DevOps, so its cron has never fired. An unregistered scheduled lane is SILENT, not red — which in a status table is indistinguishable from a lane that ran and found nothing. Run it by hand (registry check-story-contract --tag FOLIO-UNIFY, with ADO_TOKEN set) until an operator performs the two steps in .azuredevops/pipelines/lane-registration.json (AB#13541).

The waiver may only shrink: the check fails with STALE-BASELINE on any entry that no longer violates.

Running it​

# Live, against Boards. Needs SYSTEM_ACCESSTOKEN (pipeline) or ADO_TOKEN (local).
npx --prefix tools/registry tsx tools/registry/src/cli.ts check-story-contract --tag FOLIO-UNIFY

# Ignore the waiver and show the true, undiscounted debt.
npx --prefix tools/registry tsx tools/registry/src/cli.ts check-story-contract --tag FOLIO-UNIFY --strict

# Offline, from a saved ADO workitemsbatch response (array or {value:[...]}).
npx --prefix tools/registry tsx tools/registry/src/cli.ts check-story-contract --from cards.json

Exit 1 on any un-waived violation or any stale waiver entry. An empty read throws rather than reporting a clean run — a gate that reports "0 violations" on a read that returned zero cards is worse than no gate.

Open question for the operator​

AB#13555 asks for one of two rulings, and the gate is built so that either answer works without a code change:

  1. hospitality-ba authors story records retroactively for the FOLIO-UNIFY slices. Then the story-link limb is satisfied by citing those paths, and the waiver shrinks.
  2. The Boards card body IS the contract for this programme. A legitimate answer — but then it must be recorded as the ruling, and each card needs an "Acceptance Criteria" section in its description so a reviewer has something to hold the diff against. The story-link limb would then need an operator decision to relax.

Until one is ruled, this page and the waiver file are the record that the absence was measured rather than overlooked.

Source​

WhatWhere
Checker (pure, unit-tested)tools/registry/src/linters/check-story-contract.ts
Teststools/registry/__tests__/linters/check-story-contract.test.ts
CLI handlertools/registry/src/handlers/check-story-contract.ts
Boards reader (network, thin)tools/registry/src/lib/ado-work-items.ts
Waiver listregistry/story-contract-baseline.json
Gate lane.azuredevops/pipelines/story-contract-gate.yml