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:
| State | Cards | Violating |
|---|---|---|
Closed-Verified | 56 | 56 |
In-Review | 20 | 20 |
Proposed | 157 | 157 |
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-emptyMicrosoft.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— noSystem.LinkTypes.*work-item link, and no story-record path (adocs/**.mdpath) cited in the description or comments. A bareCLAUDE.mdmention 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-docsalone. (AB#13547 citesdocs/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-BASELINErule 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:
hospitality-baauthors story records retroactively for the FOLIO-UNIFY slices. Then the story-link limb is satisfied by citing those paths, and the waiver shrinks.- 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
| What | Where |
|---|---|
| Checker (pure, unit-tested) | tools/registry/src/linters/check-story-contract.ts |
| Tests | tools/registry/__tests__/linters/check-story-contract.test.ts |
| CLI handler | tools/registry/src/handlers/check-story-contract.ts |
| Boards reader (network, thin) | tools/registry/src/lib/ado-work-items.ts |
| Waiver list | registry/story-contract-baseline.json |
| Gate lane | .azuredevops/pipelines/story-contract-gate.yml |