KeyNova Sentinel — PMS Phase 1: consolidated state of the platform
Date: 2026-08-06 · Environment: TEST · Scope: core PMS only (reservations · tape chart · front desk · housekeeping · folio/billing · invoices · payments/cash). POS, banquet, spa, CRM/loyalty, channel and analytics are out of scope by decision.
Supersedes the reading — not the measurement — of
sentinel-pms-phase-1-state-of-platform-2026-08-06.md (round 2026-08-06-09-47).
That round's numbers stand. Two of its open unknowns are now answered, and one answer
changes how its headline must be interpreted.
⚠ RETRACTED 2026-08-07 — THE HEADLINE BELOW IS WRONG
"28% of the scoreboard is unreachable" was an artefact of a bug in my own gate, not a property of the platform. The section is kept verbatim below because the correction is more useful than a clean document, but do not act on its numbers.
| published | corrected | |
|---|---|---|
| unowned flows | 27 | 0 |
| journeys blocked | 15 | 0 |
| achievable ceiling | 38 / 53 | 53 / 53 |
Third and final correction (2026-08-07). The middle column of this table was itself wrong twice. The predicate was fixed a second time and STILL under-counted, because it checked only
api || e2e || keywhileflows.mjsbinds a spec through six fields — measured across its 142 rows:api=83 · e2e=50 · sec=10 · perf=3 · conc=2 · chaos=2. Seventeen bindings were invisible. The predicate now DERIVES ownership (any value matching/\.(spec|test)\.(ts|mjs|tsx)$/, or a batterykey) rather than listing field names, because a hard-coded list had already drifted twice. Guard test + mutation proof inregistry/journey-ceiling.test.mjs(commitd8440c7e).
registry/journey-ceiling.test.mjs filtered on !f.api && !f.e2e against CAPABILITY_MAP, whose rows
are exactly {id, module, feature, title, dims} — neither key exists there, so the predicate was
unconditionally true. It is the identical reader-keyed-on-a-field-no-writer-emits class documented in that
same file for f.flow → f.title: I fixed it there and left it standing thirty lines above.
It was also conceptually wrong: it treated a battery key as "not an owner". The battery journey IS the
owner of the workflow dimension. Five of the eight flows I called "blocked" already roll up GREEN.
ONE journey is structurally blocked, not fifteen. Fixed in 02271fb2; found by certification-steward
and verified against the source before acting.
Read this first: the headline was measured against an unreachable denominator
The round-3 report led with 14 of 53 staff journeys GREEN.
Re-derived 2026-08-07 through the real adapter (
verdictsFromRound→flowVerdict→useCaseVerdict, againstresults/sentinel-2026-08-06-09-47.json), because everything this section originally claimed around that number was wrong.FLOW verdicts : GREEN 70 · FAIL 46 · INCONCLUSIVE 20 · NA 6 (142)
JOURNEY verdicts: GREEN 14 · FAIL 38 · INCONCLUSIVE 1 (53)
14 of 53is CORRECT. Every other figure this section carried is withdrawn:
claimed actual 15 of 53 journeys can never be green 1 journey is INCONCLUSIVE 27 of 142 flows unowned 0 denominator is really 38 denominator is 53 28% of the scoreboard is a coverage gap ~2% (1 of 53), and none of it is unowned-flow The real story is the opposite of a coverage gap: 38 of 53 journeys FAIL. Those are failures, not gaps — which is a worse position than the original text described, not a better one. It read 28% of the board as structurally unmeasurable when in fact almost all of it is measurable and failing.
The one INCONCLUSIVE journey comes from flows whose specs did not produce a verdict in that round (20 flows INCONCLUSIVE at flow level), NOT from any flow lacking an owner — the ceiling is 53/53. Do not conflate "no verdict this round" with "no spec exists".
Two standing unknowns, closed
"Why is the Accountant 0/3?"
Not a platform failure. Two of the three journeys are structurally uncertifiable:
FD-PROFORMA, FD-TAXINV and GST-GSTR1 each declare a workflow dimension and own no
spec. The third (ac-city-ledger-ar) is spec-backed at every flow and its L1 owner now
runs 4/4 green.
Round 1 blamed this on the half-seeded fixture. That was half right: the fixture fault was real, and fixing it did not move the number, because the remaining cause was structural.
"What do the five 'check-in did not surface an OPEN folio' specs actually hit?"
A fixture collision, reported as a folio defect. The server had been saying so all along:
HTTP 409 ROOM_ALREADY_ASSIGNED
"Room 101 is already assigned to another reservation for these dates."
Nine specs passed rooms[0].id; on the shared TEST property that room carries 80
reservations, one CHECKED_IN for today. A bare catch {} in the shared helper had been
discarding the server's own refusal. Two triage hypotheses (event bus down, invalid Aadhaar
checksum) were burned before anyone read it.
Picking the first assignable room is not sufficient either: of 88 assignable rooms, 50
were VACANT_DIRTY, and check-in refuses those with 409 Room is not ready.
Assignability and readiness are different questions. pickReadyRoom now intersects both.
Result: the folio suite went 4/9 suites → 9/9, and 5/18 cases → 18/18.
The instrument was broken in ways that inflated the defect count
This is the most consequential category of the day. None of it was a platform defect; all of it presented as one.
1. certify.mjs --all could not run at all
The full certification suite died in jest globalSetup, so not one spec in the catalogue
executed. The error it printed:
403 FEATURE_NOT_AVAILABLE: Feature 'pos.basic' is not available on your current plan
which names a feature flag and points nowhere near the fault. The real cause was ten steps
earlier: the SUPER_ADMIN login failed, so the fresh tenant was never upgraded to
ENTERPRISE. The built-in default admin@keynovahotels.com returns 401 on TEST;
demo-admin-rich@keynova.ai works.
It stayed hidden because every round reuses the cached uat-context.json instead of
provisioning — that cache was doing load-bearing work nobody intended.
Fixed: the plan upgrade now throws at the cause, naming the credentials to export.
2. One line of local config produced 152 identical failures
jest.setup.ts loads `.env` — NOT `.env.test`.
.env.test is tracked, looks authoritative, and holds a working credential. It is
never read. .env is gitignored, so it drifts per machine, and here it carried
QA_ADMIN_EMAIL=admin@keynovahotels.com.
Result: 152 identical HTTP 401 on POST /api/auth/login across 26 suites, each
presenting as a different subsystem breaking.
Proof it was the only fault: re-running ari-grid with the working credential and
nothing else changed took it from 0/10 → 8/10.
Why it hid: suites using fixtureMode:'onboard' log in as the provisioned tenant's own
admin and never touch env.adminEmail — so the specs most often run by hand were immune.
Only a full catalogue run surfaced it.
Two theories were eliminated by measurement before the cause was found: brute-force
lockout keys on email, not IP (a bad account cannot lock a good one); and 25 sequential
logins on the good account all returned 200 (not the 5-session cap). Only the harness's own
Reproduce: curl line — which prints the email — identified it.
Fixed with a preflight, not a better default (the valid account differs per
environment): globalSetup proves the admin can log in once and, on failure, names the
base URL, the email, the response, and the fact that .env decides it.
Mutation-verified in both directions.
3. Credential fallbacks that dead-end at an invalid literal
The preflight only validates QA_ADMIN_EMAIL; specs resolving their own credential bypass
it. Several fell back to the dead literal through a different variable name.
api-key0/6 → 6/6confirmation-number-prefix0/5 → 3/5vishramgruh— deliberately not chained. Chaining it made the suite log in as a different tenant and its own JWT tenant-claim assertion caught that in one run (Expected 00000000-…000002, Received a0000000-…099999). A credential fallback must never cross a tenant boundary the spec is asserting on.
4. A spec pinned to a hardcoded property
email-invoice-delivery failed every case with 403 … does not belong to the asserted tenant. The property was a constant, so FIXTURE_MODE=onboard could not rescue it — the
spec was unrunnable anywhere the canary was absent. Now resolved from the JWT's
propertyId claim.
5. Fixture-property saturation
The shared UAT property had accumulated 664 reservations; the Standard room type was
booked 52 of 52 while 48 rooms of other types sat empty. Specs failed
409 OVERBOOKED — Insufficient availability, which reads exactly like a product defect.
31 stale fixture reservations reaped (harness-created only, nothing under 2 hours old, each release verified by a separate-request read-back). One full round re-saturated it, so the fixture now selects a room type that has inventory, announcing any substitution.
Environment constraints that bound what can be certified
| Constraint | Consequence |
|---|---|
Both candidate canary tenants carry isDemoTenant=true (00000000-…000002 KeyNova Hospitality Group, a0000000-…099999 [DEMO] KeyNova Demo Resort) | reserved-fixture-integrity refuses certification on a demo tenant (AB#10771): e-invoice/IRN returns SKIPPED_DEMO_TENANT and notification channels short-circuit to mocks. There is no non-demo canary to point at — "repoint the canary" is never an available fix. |
| No Vishramgruh-tenant admin exists on TEST | That 13-case suite cannot run; borrowing a generic admin is worse than failing. |
| Fresh provisioning depends on a super-admin that must be exported | Now fails loudly at the cause rather than 10 steps later. |
AB#11248 — the inspection FAIL verb: built, verified, and it found two live defects
Shipped across all four layers, each with a registered owner, each verified on the deployed pod. Two defects were found only by driving the browser — every API-level test passed over both.
| Sev | Defect | Evidence | State |
|---|---|---|---|
| P1 (data) | A room rejected for "bathroom not clean" was written INSPECTED — a sellable state. The front desk could sell a room that had just failed inspection. routing.room said DIRTY in the same response body. | Two writers for one fact: the outbox payload was routing-aware, the synchronous property-service call passed a hardcoded 'INSPECTED'. | Fixed 82a7d05; verified live — the same probe now returns housekeepingStatus=DIRTY. |
| P2 (display) | The toast said "Inspection passed — room marked Inspected" after a FAIL — on the very feature built to stop a failure being recorded as a pass. | The hook read outcome off the {success,data} envelope. | Fixed 41351f49 at the client boundary. |
Why 6/6 passed on a broken build: the L1 spec asserted body.routing.room — the
computation — and never read the room back. Asserting a computed value is not asserting
its effect.
Protection added:
- L1 S1b — reads the room back in a separate request, taking the server's own routing decision as the expectation. Shown red on the deployed build first.
- Invariant gate
inspection-room-outcome.gate.spec.ts— enumerates the failure reasons from Prisma, so a reason added later is covered the day it is added, and pins that the outbox target and the synchronous write agree. Mutation-proven: restoring the hardcoded value turns 15 of 21 red. - L2 + L3 — 5/5 green on pod
test-91a08965, registered. The L3 mutation printed the live sentence: "Inspection failed. Room set to Dirty — needs a full re-clean. A rework task was raised." reworkCountobserved = 1 on the Housekeeping Productivity report, withtasksCompleted: 0alongside it — the rework is counted and correctly excluded from productivity (AB#11268 + AB#11271 verified together).
L1 certification: what the numbers actually say
Full round, quoted verbatim from the runner rather than summarised:
Test Suites: 124 failed, 1 skipped, 160 passed, 284 of 285 total
Tests: 484 failed, 32 skipped, 3 todo, 1219 passed, 1738 total
Correction (2026-08-07). This section previously read "408 suites · 1219 passed · 484 failed". The case counts were right; 408 was not. The real suite total is 285 (284 ran, 1 skipped) — and
408 = 124 failed + 284 total, i.e. the failed suites were ADDED to the total instead of being part of it. 124 failed + 160 passed = 284 reconciles exactly.Two omissions are restored above because they change how the number reads: **32 skipped
- 3 todo** (35 cases that ran no assertion at all — material in a programme about vacuous tests), and the denominator 1738, without which "1219 passed · 484 failed" invites being read as 1219/1703.
Note the two rates differ and the suite-level one is starker: 124 of 285 suites failed (43.5%) against 484 of 1738 cases (27.8%). Quote whichever is meant, and say which.
Raw counts overstate the defect position roughly fourfold. Partitioned by reading every failure message:
The 30 suites invalidated by the credential fault, re-run clean
| Count | |
|---|---|
| Distinct failing cases | 98 |
| — genuinely vacuous (assert nothing; known backlog) | 66 |
| — real failures | 32 |
| — of the real, out of scope (POS, channel, ResAvenue, notifications, loyalty) | 10 |
| — of the real, in core-PMS scope | 15 |
The 15 in-scope failures reduce to 3 root causes
(stop-sell proved to be the SAME hardcoded-property class as email-invoice-delivery, not a fourth.)
| Suite | Failures | Root cause | State |
|---|---|---|---|
vishramgruh-full-flow | 9 | One cascade from a dead credential | Diagnosed; needs a Vishramgruh-tenant admin that does not exist on TEST |
email-invoice-delivery | 4 | Spec hardcoded a property | Fixed 7847fd68 |
reserved-fixture-integrity | 1 | Demo-tenant guard firing correctly | Not fixable — no non-demo canary exists |
stop-sell-negative-cert | 1 | Same class as above — property/tenant were constants, plus a canary-only BAR-<suffix> pair convention | Fixed f019897b — 0/6 → 6/6 |
Decisions taken while the operator was away (2026-08-06/07)
Each of these was a judgement call made with SME/BA input rather than waiting. Reverse any of them freely.
D1 — Fix the night-audit date root cause rather than measure the double-post first
Chosen by the operator before stepping away. The alternative was to first prove the legacy-branch
double-post end to end. Fixing the root closes the whole class (wrong-day close, dead duplicate guard,
analytics mis-keying, and the double-post) in one deletion, so the measurement became optional rather
than blocking. Shipped: 09ff585. Mutation-proven; 382/382 night-audit tests green.
D2 — The refusal is NARROW, not "currentBusinessDate IS NULL"
The SME proposed refusing the audit whenever currentBusinessDate is unset. The BA overruled that and
I followed the BA: refusing on NULL alone makes a property's FIRST-EVER audit impossible and bricks
every currently-seeded property. The refusal (NIGHT_AUDIT_NO_OPENING_BUSINESS_DATE) now fires only when
no canonical source can answer — not the column, not a prior completed audit, not goLiveDate.
⚠ Consequence to sequence: the data remediation that back-fills goLiveDate must run BEFORE this
refusal reaches a live environment, or the first cron after deploy starts refusing properties that were
trading fine yesterday.
D3 — Rollback split into a linked sibling story, not folded in
The SME wanted the rollback defect in the same card. The BA argued for a split and I followed it:
postingDate: audit.auditDate exact-equality is already incomplete on the canonical branch since
AB#11233, so folding it in would let a reviewer believe the date fix repairs rollback — it does not. Its
real fix is stamping nightAuditLogId on every posted row, a schema change with its own blast radius.
D4 — Made the credit-note chain B2B rather than documenting why it could not run
folio-void-creditnote Scenario 4 failed naming its own fix ("settle the folio → IRN"). That written fix
was wrong — AB#9897: IRN IS B2B-ONLY, so a B2C folio can never file a credit note however often it
is settled. I billed the chain to a company instead. §34 credit notes went 5/6 → 6/6, mutation-proven.
D5 — Stopped an out-of-scope investigation mid-flight
I had begun diagnosing a loyalty enrolment failure before recognising loyalty is CRM-owned and outside the locked core-PMS scope. Dropped it. 10 of the round's real failures are out-of-scope and remain counted but uninvestigated.
D6 — The "day not ended" gate is NOT shipped, and the pre-deploy check is why
The SME's original ruling was to refuse when currentBusinessDate >= property-local today. I declined to
ship it and asked for the predicate plus the rollout posture. The SME then reworked its own item: the
naive >= would refuse a legitimate 23:00 end-of-day — and the platform ships a tenant-config default of
exactly nightAuditHour: 23, so it would have broken hotels running normal practice.
The corrected rule (not yet shipped): compare resolveClosingBusinessDate(...).key against the
property-local today key as STRINGS. > blocks · === warns (early EOD is normal) · < proceeds silently
· the CLOCK_DAY branch is not gated at all. A fresh property needs no exemption — goLiveDate = today
lands on ===, so it warns rather than blocking, and its first audit still runs.
Why it is not shipped. The SME gave a read-only pre-deploy check, and I ran it:
34 of the readable properties on TEST carry a business date in the FUTURE — up to 6 days ahead (
cbd = 2026-08-13against a local today of2026-08-07).
PATCH /properties/:id/business-date refuses a rewind of more than one day, so those 34 properties
cannot be brought back. Shipping the block today converts a silent data condition into a hard outage:
every one of them becomes permanently unable to close a day, with no operator remedy anywhere in the
platform.
Sequence, per the SME and adopted: (1) build an audited SUPER_ADMIN-only business-date correction
path that may move the date back more than one day — refusing to cross a COMPLETED audit and refusing to
land in a filed GST period; (2) correct the affected properties; (3) then ship the block. It is
deliberately NOT profile-gated and NOT warn-first: this is a well-formedness question about the run's own
subject, not a strictness preference, and LEAN is the estate-wide default so a profile row would fire for
nobody.
The harm being prevented, stated precisely: the no-show sweep is arrivalDate <= closing date. A run
that closes a date in the future flips tomorrow's arrivals to NO_SHOW, posts their retention fee, and
releases their rooms to sale.
Still needing you
- A non-demo tenant on TEST — both candidates carry
isDemoTenant=true, so e-invoice/IRN and notification flows cannot be certified truthfully at any level of effort. No workaround invented. - A Vishramgruh-tenant admin — that 13-case suite cannot run; borrowing a generic admin makes it log in as the wrong tenant, which its own isolation assertion correctly catches.
Open items
| Item | Notes |
|---|---|
| 25 remaining vacuous cases | Plan committed: sentinel-zero-assertion-remediation-2026-08-06.md. Highest priority: 8 in pms-j-night-audit, the most financially consequential nightly operation. |
| Element review of 89/89 evidence screens | Nothing reaches GREEN without it. |
| 27 unowned flows | Each declares a dimension nothing tests. Now named in the report; closing them is the route to lifting the journey ceiling from 38 toward 53. |
| Sentinel P0 #3 — sharer identity | Split-by-pax falls back to the ordinal placeholder Share-2 when no per-occupant name was captured. The spec goes green only when the fixture happens to capture a companion; it should construct both paths. |
CHARGE_ROUTING_HELD / MULTIPLE_OPEN_GUEST_FOLIOS | Cancellation charges are created but parked because a reservation has more than one open guest folio and the service correctly declines to guess. Whether a fresh reservation should ever have two is worth answering. |
| Half-applied cancellation in TEST data | cancellation-saga reconciliation: scanned=497 flagged=1. |
What this round establishes about method
Every one of the following was a real error made and corrected today, and each is now a gate or a recorded rule rather than a lesson:
- Asserting a computed value is not asserting its effect. A response echoing what it did is a hypothesis; read the changed record back in a separate request.
- Read the server's own refusal before theorising. Five specs, one symptom sentence,
and the cause was in a message a bare
catch {}had discarded. - Classify by reading messages, never by matching substrings — wrong five times in one day, in both directions.
- Count root causes, not failures. 126 → 15 distinct signals; 249 → ~4.
- A wait that furniture satisfies is not a wait. An L3 toast assertion passed against an empty notification landmark, inside the file written to catch a screen that lied.
- Mutation-test, and assert the restore. A blind string replace hit the wrong occurrence twice.
- A verdict must gate on the action having succeeded. A control printed "PRODUCT DEFECT" for a check-in the server had refused.
- Only an observation on the deployed pod closes a card.
- A reader keyed on a field no writer emits degrades to an empty list — and an
empty list is indistinguishable from "no data".
stop-sellS4 sat vacuous under a comment blaming "intermittent canary availability instability"; the endpoint was healthy and the spec was readingroomTypesfrom a producer that emitsrooms. A plausible written alibi is not a diagnosis.
Bottom line. The platform is in better shape than the raw counts suggest, and the certification instrument was in worse shape than anyone knew. The single most valuable change today is not a product fix — it is that the scoreboard can no longer report a coverage gap as a platform failure, or a broken credential as 152 defects.
The 27 unowned flows — RETRACTED IN FULL (2026-08-07)
⚠ EVERY NUMBER IN THIS SECTION IS WITHDRAWN. It was written against a defective predicate, not against the data. Re-measured with ownership derived from all six spec-binding fields: 0 unowned flows, 0 blocked journeys, achievable ceiling 53 of 53. There is no coverage-driven ceiling today, and "27 unowned flows" was never a real work list.
PLAT-EVENT-LATENCYis the clearest case of what went wrong: it carriesperf: 'event-propagation-latency.flow.spec.ts'and that spec runs GREEN and NON-VACUOUSLY (1/1, 4.3s, zero-assertion gate active and silent) — yet it was listed below as blocking a journey.One caveat, so this is not over-read the other way: ceiling 53/53 means every flow binds a spec. It does NOT mean those specs are green or non-vacuous — binding and health are different questions and this gate measures only the first. The night-audit suite is the proof: bound the whole time, while 16 of its 20 cases never executed.
The triage below is kept for provenance only. Do not plan from it.
The report said 27 flows declare a dimension and own no spec, blocking 15 of 53 journeys. Neither number survived measurement.
Only 12 of the 27 block any journey at all
| blocks | flow | dim |
|---|---|---|
| 3 | FD-CHECKOUT, GST-GSTR1 | workflow |
| 2 | RSV-CORP-B2B, FD-UPGRADE, FD-PROFORMA, FD-TAXINV | workflow |
| 1 | FD-INCIDENTAL, FD-SWAP, PLAT-EVENT-LATENCY, CONC-CORE, REL-IDEMPOTENT, RSV-NO-OVERSELL | mixed |
The other 15 block zero journeys — almost all sec / chaos / perf dimensions that no staff journey
traverses. They do not affect the ceiling, and closing them will not move 38 → 53. Any plan that
treats all 27 as ceiling work is mis-scoped.
A SPEC ALREADY EXISTS for at least 13 of them
| flow | existing spec |
|---|---|
SEC-BRUTEFORCE | brute-force-lockout.flow.spec.ts |
SEC-TENANT-ISO | tenant-isolation.flow.spec.ts |
SEC-RBAC | rbac-integrity.flow.spec.ts |
SEC-CUSTOM-ROLES | custom-roles.flow.spec.ts |
SEC-ROLE-CHANGE | role-change-takes-effect.flow.spec.ts |
SEC-MFA-TOTP | mfa-totp.flow.spec.ts |
SEC-APIKEY | api-key.flow.spec.ts — measured 6/6 green today |
SEC-WEBHOOK | channel-webhook-security.flow.spec.ts |
RSV-NO-OVERSELL | no-oversell-cross-guest.flow.spec.ts |
REL-IDEMPOTENT | idempotency-replay.flow.spec.ts |
CONC-CORE | concurrency-extended.flow.spec.ts |
REL-CHAOS | chaos-failure-modes.flow.spec.ts |
GST-GSTR1 | gstr1-period-boundary.flow.spec.ts (name suggests partial — verify scope) |
So the work is mostly BINDING, not authoring. That is a materially cheaper piece of work than "write 27 owner specs", and it changes the sequencing of the whole item.
⚠ What must NOT happen next
Do not bulk-bind these. The standing rule is run green AND non-vacuously BEFORE registering — and today alone produced three cases that were green while asserting nothing, one that asserted the defect as correct, and one written to pass only in the broken world. Binding a flow to a spec that has never been run is how a coverage gap becomes a FALSE GREEN, which is strictly worse: the gap is then invisible.
Per flow: run it → confirm green → confirm non-vacuous (no zero-assertion, no soft-skip that no-ops the
assertion) → confirm the spec's scope actually matches the flow's declared dimension → then bind.
SEC-APIKEY is the only one with a measurement today (6/6) and is the safest first candidate.
The genuinely UNOWNED, journey-blocking, must-author set is therefore closer to: FD-CHECKOUT,
FD-PROFORMA, FD-TAXINV, RSV-CORP-B2B, FD-UPGRADE, FD-INCIDENTAL, FD-SWAP,
PLAT-EVENT-LATENCY — eight, not twenty-seven.
The workflow dimension needs a BATTERY KEY, not a spec (2026-08-07)
Binding lifted the ceiling 38 → 40 for flows whose declared dimension is sec / conc / chaos — those
are jest-runner dimensions, so an api: spec satisfies them.
It will NOT work for the eight remaining journey-blocking flows, and this is the precise reason:
adapter-flows.mjs:13—if (f.key) dims.workflow = { key: f.key }. Theworkflowdimension is declared only when a flow carries a money-battery key.round-verdicts.mjs:23—ROUND_DIM_TO_REGISTRY_DIM = { money: 'workflow', … }. The runner'smoneydimension (DIMENSIONS,field: 'key',runner: 'battery') maps onto it.
So workflow is satisfied by the money battery, keyed on flow.key — never by an api: or e2e:
binding. FD-CHECKOUT, GST-GSTR1, FD-PROFORMA, FD-TAXINV, RSV-CORP-B2B, FD-UPGRADE,
FD-INCIDENTAL and FD-SWAP each declare workflow and carry no key, so no amount of spec-writing
will clear them. They need either a battery journey key, or a decision that the flow's real dimension is
api and the declaration is wrong.
⚠ A correction to my own reasoning, recorded because the near-miss is the lesson. I first concluded
this was a NAME MISMATCH — that the adapter wrote workflow while the runner wrote money, so the
dimension could never resolve. That would have been a significant registry bug. It is false: the
mapping table exists and is correct. I checked verdictsFromRound before writing it up. The real finding
is narrower and more useful — not "broken", but "satisfied by a different runner than the one people
assume".
Consequence for planning: the remaining 13 blocked journeys split into (a) 19 flows that declare
workflow and already HAVE specs — those specs are not the missing piece; and (b) seven specs that exist
but are red/skipped and must be repaired before binding. Neither bucket is "write more tests".
Where I disagree with the steward's remediation sequence (2026-08-07)
The certification-steward review is accepted in full except step 1's second half: "add an info case
to toVerdict (round-verdicts.mjs)".
Verified: toVerdict has cases for pass/fail/error/warn/skipped and default: INCONCLUSIVE
(registry/round-verdicts.mjs:37-46), so an info status is permanently INCONCLUSIVE. The steward is
right that this makes a class uncertifiable. It is wrong about where to fix it.
flows.mjs produces info in two places:
:411—see fields|see lines→ a DESCRIPTIVE token; the assertion recorded data and decided nothing:430—return 'info'as the catch-all for every unrecognised token
Mapping info → GREEN would therefore certify every verdict the classifier does not recognise. That
is the exact hazard the file's own docstring warns about — "an unknown state must not be able to certify
a journey" — and it is a strictly worse version of the PROFORMA_NO_SERIAL substring bug fixed today,
because it would apply to the whole vocabulary rather than one token.
info → INCONCLUSIVE is correct. The defect is upstream: a battery journey whose verdict token is
'see lines' has computed an assertion and refused to decide it. The steward's own per-flow ruling says
exactly this for FD-INCIDENTAL — "decide alcohol_zero_gst_assert into the verdict token" — which is a
BATTERY fix, not a toVerdict fix. The two halves of its step 1 contradict each other; the per-flow half
is right.
Not done here, deliberately: the three success-token renames (B2B-bound,
ENDPOINT_RETIRED_AND_ROOMMOVE_BILLS, see lines) and the four computed-but-never-decided assertions
(everyFolioHasSerial, alcohol_zero_gst_assert, rowCount, is18pct). Each changes what a battery
journey reports, and verifying them requires a battery re-run I cannot complete in this session. Changing
verdict tokens without re-running is shipping unverified — the discipline that has held all day.