Skip to main content

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.

publishedcorrected
unowned flows270
journeys blocked150
achievable ceiling38 / 5353 / 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 || key while flows.mjs binds 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 battery key) rather than listing field names, because a hard-coded list had already drifted twice. Guard test + mutation proof in registry/journey-ceiling.test.mjs (commit d8440c7e).

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, against results/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 53 is CORRECT. Every other figure this section carried is withdrawn:

claimedactual
15 of 53 journeys can never be green1 journey is INCONCLUSIVE
27 of 142 flows unowned0
denominator is really 38denominator 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-key 0/6 → 6/6
  • confirmation-number-prefix 0/5 → 3/5
  • vishramgruh — 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​

ConstraintConsequence
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 TESTThat 13-case suite cannot run; borrowing a generic admin is worse than failing.
Fresh provisioning depends on a super-admin that must be exportedNow 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.

SevDefectEvidenceState
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."
  • reworkCount observed = 1 on the Housekeeping Productivity report, with tasksCompleted: 0 alongside 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 cases98
— genuinely vacuous (assert nothing; known backlog)66
— real failures32
— of the real, out of scope (POS, channel, ResAvenue, notifications, loyalty)10
— of the real, in core-PMS scope15

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

SuiteFailuresRoot causeState
vishramgruh-full-flow9One cascade from a dead credentialDiagnosed; needs a Vishramgruh-tenant admin that does not exist on TEST
email-invoice-delivery4Spec hardcoded a propertyFixed 7847fd68
reserved-fixture-integrity1Demo-tenant guard firing correctlyNot fixable — no non-demo canary exists
stop-sell-negative-cert1Same class as above — property/tenant were constants, plus a canary-only BAR-<suffix> pair conventionFixed 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-13 against a local today of 2026-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​

ItemNotes
25 remaining vacuous casesPlan 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 screensNothing reaches GREEN without it.
27 unowned flowsEach 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 identitySplit-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_FOLIOSCancellation 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 datacancellation-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:

  1. 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.
  2. 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.
  3. Classify by reading messages, never by matching substrings — wrong five times in one day, in both directions.
  4. Count root causes, not failures. 126 → 15 distinct signals; 249 → ~4.
  5. 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.
  6. Mutation-test, and assert the restore. A blind string replace hit the wrong occurrence twice.
  7. A verdict must gate on the action having succeeded. A control printed "PRODUCT DEFECT" for a check-in the server had refused.
  8. Only an observation on the deployed pod closes a card.
  9. A reader keyed on a field no writer emits degrades to an empty list — and an empty list is indistinguishable from "no data". stop-sell S4 sat vacuous under a comment blaming "intermittent canary availability instability"; the endpoint was healthy and the spec was reading roomTypes from a producer that emits rooms. 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-LATENCY is the clearest case of what went wrong: it carries perf: '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​

blocksflowdim
3FD-CHECKOUT, GST-GSTR1workflow
2RSV-CORP-B2B, FD-UPGRADE, FD-PROFORMA, FD-TAXINVworkflow
1FD-INCIDENTAL, FD-SWAP, PLAT-EVENT-LATENCY, CONC-CORE, REL-IDEMPOTENT, RSV-NO-OVERSELLmixed

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​

flowexisting spec
SEC-BRUTEFORCEbrute-force-lockout.flow.spec.ts
SEC-TENANT-ISOtenant-isolation.flow.spec.ts
SEC-RBACrbac-integrity.flow.spec.ts
SEC-CUSTOM-ROLEScustom-roles.flow.spec.ts
SEC-ROLE-CHANGErole-change-takes-effect.flow.spec.ts
SEC-MFA-TOTPmfa-totp.flow.spec.ts
SEC-APIKEYapi-key.flow.spec.ts — measured 6/6 green today
SEC-WEBHOOKchannel-webhook-security.flow.spec.ts
RSV-NO-OVERSELLno-oversell-cross-guest.flow.spec.ts
REL-IDEMPOTENTidempotency-replay.flow.spec.ts
CONC-COREconcurrency-extended.flow.spec.ts
REL-CHAOSchaos-failure-modes.flow.spec.ts
GST-GSTR1gstr1-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 }. The workflow dimension is declared only when a flow carries a money-battery key.
  • round-verdicts.mjs:23 — ROUND_DIM_TO_REGISTRY_DIM = { money: 'workflow', … }. The runner's money dimension (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.