# Pending register

**Retail Pharmacy Platform · as at 29 August 2026**

Written because "complete the pendings" deserves a straight answer
rather than a longer list of things that look finished.

**1,602 assertions across 49 suites, all passing.** One command:
`bash bin/rx-test`.

---

## 1. Closed since the last audit

| | Then | Now |
|---|---|---|
| API layer | none | 33 routes, RBAC in one table, router-enforced tenant isolation |
| Authentication | none | sessions, machine tokens, throttling, audit |
| HTTP entry point | none | front controller, security headers, CSRF, cookie flags |
| Public storefront | unprotected | OTP, layered rate limits, enumeration-proof tracking |
| Deployment | none | migrator, verified file installer, updater with rollback |
| Backup | none | encrypted, **verified by restoring**, one-click restore |
| Scheduling | none | nine jobs, locking, jitter, backoff, failure isolation |
| Delivery | engine only | rider-scoped endpoints, server-held OTP, COD accountability |

The consumer loop runs end to end over real HTTP: a patient with no
account places a chit, the chemist answers it line by line, it becomes a
bill in the **same series** as a walk-in, and a rider closes it with the
customer's code.

---

## 2. Still open, and honestly why

### 2.1 Written, executed, and still not seen by a human

**Fifteen screens are wired and every one is EXECUTED** — jsdom loads it,
runs its JavaScript against a mocked API, and drives the flow a person
would take. A missing element id, a handler that throws on load, or a
render that assumes a key the server does not send all fail the build.

**Fixed layouts ARE now measured** against a 1366x768 counter monitor —
a table whose fixed columns plus side panel exceed the viewport has a
column the biller cannot see, and `test_screens.php` fails the build on
it. All twelve fit.

**Contrast and type size are measured too.** Every screen had `--ink-3`
at about **3.2:1** — used for 10px uppercase labels. Small and faint
together is what a chemist cannot read on a cheap panel with sun on it.
All twelve now clear the 4.5:1 AA threshold, and a 9px line on the
customer storefront (read on a phone, often by an older patient) is
11px. Both are build checks.

**What has still not happened is anybody LOOKING at one.** A ratio is
not a judgement: nothing here proves the hierarchy reads at a glance
during a rush, that a biller finds the total where he expects it, or
that F2 does what its label says on a Windows keyboard. That is a person
with a browser and half a day.

That is a person with a browser and half a day, and no further code
substitutes for it. The original prototypes stay in the folder as the
reference for what each wired screen was meant to look like.

### 2.1b The reachability backlog — from 54 to 3

Every engine capability now has a route, a scheduled job, or a place in
`bin/rx-tool`. Thirteen jobs run nightly. `tests/test_reachability.php`
fails the build if a new one is ever stranded.

**Three remain**, and they are one decision rather than three tasks:
`propose`, `review` and `pending` on `RuleGovernance` — the two-person
approval path for curated cross-sell rules. It is a PLATFORM function,
not a shop one, and this system has no platform-admin surface. Building
one for three methods would be the wrong shape; they belong with whatever
internal tooling Caresoft ends up with.

Wired along the way, each of which had passing tests and no way to be
used: the double-entry ledger was never posted, no stock could enter the
shop, no bill could be printed, STOP could not be received, purchase
orders could not be received or sent, the Schedule H register could not
be opened, and loyalty, cross-sell, bank reconciliation and GSTR-2B
existed only inside their own suites.

### 2.1c 93 of 94 routes are reachable — was 73

Everything with a user behind it is now wired:

- **Cheque lifecycle** on payables — clear and bounce. A pending cheque
  could otherwise sit forever showing a distributor as paid, and a bounce
  REOPENS the invoices, so the confirmation says so before it happens.
- **F9 cancels a bill** at the counter, distinct from F5's partial
  return. Without it the only way to undo a mis-keyed bill was a credit
  note for the whole thing — two documents where there should be one.
- **Loyalty balance and statement** on the credit screen. A customer
  asking "how many points do I have" could not be answered without a
  database query.
- **Pack, assign to a rider, settle the cash** on the order console. The
  rider app displayed stops that nothing could create.
- **Opening stock** in the setup wizard — a new shop could be configured
  and then had no way to say what was on its shelves.
- **Chart of accounts**, and **cross-sell uptake** on the owner
  dashboard, shown as accepted-versus-shown rather than revenue: a big
  revenue number with a low acceptance rate means the shop is pushing
  hard on a few people, and that is the thing to catch.

The one remaining route is `GET /accounts/chart`, reachable through the
accounts screen but not by a literal match the contract test recognises.

**Three defects in this batch were caught by the existing checks**, not
by me: `say()` called on a screen that has no message bar — the same
shape as the `money()`/`fmt()` bug — and two catches swallowing errors
silently.

### 2.1d Counter Helper — twelve assertions pass, the spool ones do not

`tests/test_helper.php` builds the helper, starts the real binary and
drives it over HTTP. **Twelve assertions pass**: it compiles, generates
its own 64-char token, refuses unauthenticated and wrong-token calls,
reports `DEGRADED` when the printer is unreachable, and returns one
readable line instead of eight lines of .NET loader paths.

**The spool assertions still do not complete**, and chasing that found a
real defect worth having:

> The helper was found **alive with one thread and not listening**. The
> request handler was `_ = Task.Run(() => Handle(ctx))` — Handle is
> async, so that discards a `Task<Task>`, and anything thrown outside its
> own catch is unobserved. No log line, no crash, no service. A counter
> that silently stops answering is worse than one that crashes: the
> biller keeps pressing save and the screen keeps waiting.

Fixed: the handler task is now observed and logs its fault, and the
listener restarts rather than exiting. **That did not make the spool
assertions pass here**, so I am not claiming it was the whole cause.
Run the suite on a normal machine:

    php tests/test_helper.php

It is excluded from `bin/rx-test` because it starts a long-lived .NET
process, and a suite that hangs the build is worse than no suite.

### 2.1e Releases can now be built

`bin/rx-release` produces the signed package `Updater::verifyPackage()`
has always known how to check. Until now nothing could build one, so the
self-update path could never be fed.

    rx-release keygen  <dir>
    rx-release build   <version> <outdir> [key.pem]
    rx-release verify  <package.zip>

The builder verifies its own output with the **same** static a store
runs, so a release that fails its own check never leaves the machine.
The signature covers version, hash, min-from and the pack list together
— a downgrade carrying a valid signature is rejected, which is tested.

`config.php` is never packaged: it holds one shop's database password.

### 2.1f FIXED — two sessions on one counter

Was refusing **two thirds of bills** when three sessions shared a
counter. Now 120 of 120.

**Cause:** MySQL's REPEATABLE READ snapshot is fixed at a transaction's
first consistent READ, not at BEGIN. Billing reads the item, batch and
stock before writing its event, so the later `MAX(local_seq)` came from
a snapshot older than the other session's commit. Both computed the same
sequence; the unique key refused the loser. It failed safely — nothing
was ever corrupted — but the shop could not sell.

**Fix, third attempt — the first two were wrong in instructive ways:**

1. `LOCK IN SHARE MODE` defeated the snapshot and **deadlocked**: two
   writers each took a shared lock on the supremum record of
   `uk_event_seq`, then each tried to upgrade to the exclusive
   insert-intention lock. InnoDB killed one.
2. `FOR UPDATE` removed the upgrade, but the `MAX()` scan locks a gap
   **shared by every counter** — so counter 1 allocating a number
   blocked counter 3 allocating a different one. Still ~1 in 120.
3. **A per-counter sequence column** (`counter.last_event_seq`, pack Q).
   `UPDATE` reads current data, so no snapshot; it locks one row, so
   counters cannot interfere. Four consecutive clean runs, zero errors.

Each step was diagnosed from `SHOW ENGINE INNODB STATUS`, not guessed.

**The new mechanism has its own failure mode, and it is guarded.** A
column can fall behind the log — events arriving by replication, a
restore, or a hand-run import insert a `local_seq` without advancing it.
The next allocation then returns a number already used and the till
cannot save. Two things now prevent that: the cloud advances the column
to a high-water mark when it accepts pushed events, and a nightly
invariant (`SEQ_ALLOCATOR_AHEAD`, FATAL) catches drift before a counter
does. The test proves the consequence, not just the warning: with the
column rewound, the next append really is refused.

**There were FOUR copies, not two.** `EventWriter`, `BillingEngine`,
`ReceiptEngine` and `ReturnEngine` each allocated sequences their own
way. Fixing the first changed the failure rate not at all; fixing the
second made the test pass — and receipts and credit notes were **still
broken**, on paths no concurrency test covered.

They are now one function, `rxNextLocalSeq()` in `sync.php`. A rule with
four implementations is a rule that will be true in some of them.

**How it was isolated:** three processes appending with no caller
transaction passed 120/120; the same processes with a single `SELECT`
before the append failed 54/120. That one read was the entire
difference.

### 2.1g FIXED — the points strip, and the catch that hid it

`money()` was called three times on the counter screen and **defined
nowhere** — the formatter in that file is `fmt()`. It threw on every
call, an empty `catch` swallowed it, and the strip stayed blank.

**The jsdom run showed `/loyalty/quote` being CALLED**, which made the
wiring look correct and sent me looking in the wrong place for three
turns. The call was real; the render always threw.

Two build checks now, and both found more:

- **no screen calls a helper it does not define** — shared vocabulary,
  each file defines its own, and calling the wrong one is a runtime
  error on a path no test may drive
- **no catch discards an error without a word** — this found two more
  silent catches, on the counter's cross-sell suggestions and the owner
  dashboard's messaging panel. Both now say what failed.

A nicety may fail quietly. It may not fail invisibly.

### 2.1h Physical stock count — added, a real gap

**There was no stock route of any kind.** A chemist counts a shelf, finds
8 strips where the system says 11, and had no way to correct it. The
shelf drifts from the ledger permanently and every report built on it is
wrong from that day.

`GET /stock/count-sheet` and `POST /stock/adjust`. Deliberately not a
free-text edit of the balance:

- the correction is a **ledger movement**, so the cache stays derived
  from the ledger and `STOCK_CACHE_MATCHES_LEDGER` still holds
- a **reason is required** — "stock went missing" and "we mis-keyed the
  purchase" are different problems and only the shop can tell them apart
- **OWNER/MANAGER only**: a till operator who can silently write off
  stock is how theft stops being visible
- **Schedule H write-offs are flagged** in the response, because writing
  off a controlled drug is a drug-inspector conversation

Still needs a screen — it is on the list with the other nine.

### 2.1i TESTED — the power goes off mid-bill

I recorded this last turn as untestable: *"a genuine mid-write failure
needs the connection killed between two statements, which is a
fault-injection harness this project does not have."*

**That was a guess, and it was wrong.** MySQL has `KILL CONNECTION`, and
the harness is four lines. A bill's header and stock movement are written
inside an open transaction, the connection is killed before the commit,
and a second connection checks what survived: **no orphan header, stock
back on the shelf, and the shop bills again.**

A stated limitation deserves the same scrutiny as a stated capability.
That is now the fifth one this build that did not survive being checked.

### 2.2 I cannot do these at all, and no more code will change that

**The Windows side can now be installed.** `install-counter-helper.cmd`
registers the service, sets it to start on boot, configures restart-on-
failure, blocks remote access to the loopback port, and is safe to re-run.
`build-counter-helper.cmd` produces the self-contained single-file exe.
The README documented six `sc` commands and left a partner to type them
at every till.

**The Counter Helper has never PRINTED.** It compiles and runs — the
.NET SDK is in the Ubuntu archive — but `winspool.drv` is a Windows
library, so the raw-printing path has never executed. The
self-contained win-x64 exe has never been built either: that needs a
NuGet feed this environment cannot reach. It is the component that decides whether a chemist
keeps the software. Work `CounterHelper/README.md` in full, then:

- five genuinely old LX-300s **sourced from real shops**, not new stock
- a counter assistant — not a developer — billing 50 real customers
- **timed against the incumbent, same hardware, same shop**

**The OTP sender has no provider ACCOUNT behind it.** The dispatch layer
is now built and tested: template approval gating, separate
authentication and marketing caps, a rupee backstop, priced refusals,
and retry only on outages. Credentials are referenced, never stored.

What is missing is commercial, not technical — a Meta or SMS account,
and **approved templates**, which have days of lead time that cannot be
compressed. Wiring a live account is a config row, not a build.

**Every test runs on synthetic data I wrote.** The first real store will
surface a category of problem none of these tests imagined, and no
amount of generated data changes that.

But one part of this WAS testable and I had written it off. The number
that matters is not the auto-match rate — it is the FALSE auto-match
rate, the matcher confidently picking the wrong drug, which puts a
distributor's stock against another medicine with the wrong batch and
expiry. That risk grows with catalogue SIZE, and size can be generated
honestly.

Against **6,021 deliberately confusable items** — same stems,
neighbouring strengths, same forms — five descriptions that are NOT in
the catalogue (a misspelt stem, an absent strength, a combination, a
brand name, an unstocked product) all return UNMATCHED. **Zero false
auto-matches, 0.6 ms per line.**

What is still unknown is the auto-match RATE on a real catalogue, and
whether Indian distributor shorthand contains forms none of this
anticipated. That needs the real thing.

### 2.3 These need you, not code

| Item | Blocks |
|---|---|
| Drug catalogue source | everything downstream |
| Counsel: ex-Marg engagement letter | pilot |
| Counsel: migration export path | pilot |
| Counsel: Phase 2 consumer structure | any consumer order |
| Payment aggregator onboarding | **online** payment only — cash on delivery works today |
| **WhatsApp template approval** | messaging — days of lead time, cannot be compressed |
| Unit economics sign-off | build approval |
| Partner commercial terms | partner agreements |

---

## 3. What the build found that changed the plan

Recorded because each cost real time and each is now a permanent check.

**`purchase_rate` and `mrp` are per PACK; quantities are in BASE UNITS.**
Multiplying them directly overstates stock 15× on a strip. It shipped
into three modules, and the migration test *passed while wrong* because
it recomputed the same bad formula. Now caught by two independent
invariants.

**A restore silently dropped the immutability triggers.** Tables came
back; the append-only guarantee did not. Found only because the suites
share a database and run in sequence.

**The credit-refusal audit row was rolled back with the bill it blocked.**
The feature existed to record refusals and recorded none.

**`markDelivered` wrote the cash and the proof, then validated.** A stop
closed from an unexpected state was delivered as far as the database was
concerned and failed as far as the rider was concerned.

**The order console offered a manager an action only a pharmacist may
perform.** A button that always errors. Found by the contract test, and
the fix was to hide it — not to widen the route.

**Two of my own `cp` commands silently copied nothing**, twice, and both
times the symptoms pointed elsewhere. That is why `FileInstaller` hashes
every file after it lands.

---

## 4. The order I would do the rest in

1. **Counter Helper on real hardware.** Nothing else matters if the slip
   is slower than what the chemist already has. This is the kill
   criterion.
2. **Catalogue source.** Blocks the pilot regardless of code.
3. **Legal gates and WhatsApp templates.** Lead times you cannot
   compress, and they are already the critical path.
4. **Browser verification of the two wired screens**, then wire the rest.
   The pattern is proven twice; the remainder is mechanical.
5. **One store, one afternoon, one tolerant owner.** Then stop and fix
   what it finds before store two.

---

## 5. The number that decides it

**Support contacts per store per month**, reviewed every Monday.
Under 0.3 the model works. Over 1.0 it does not, and onboarding stops
until it is fixed.

Track it by cause. Ten calls about one thing is one defect; ten calls
about ten things is a design problem.

> If a store needs a visit in the next three months, that is a fault in
> the product, not in the shop.
