Status: BUILT, DEPLOYED AND VERIFIED LIVE (2026-09-30, §12) — the build is 09058e3. AUTHORISED by Matthew, 2026-09-30 — all four portals, one host reachable across the VeriPath network. Nothing
is built yet and no application code has been written by Hermes. The build brief is the top note on
agents/handoff/from-hermes; §10 records his answers. It is the test rig the whole supplier-portal programme has been waiting on: the evidence rule on both
scoping notes says everything before the last stage is built and verified against a synthetic portal of our own,
and stages 3–4 (outbound) and I2–I3 (inbound) cannot be verified at all until that portal exists. Matthew's decision of
2026-09-30 is the go-ahead for a basic, test-only rig. Read this with
Supplier Portals — the Next Stage (the invariants and
the security footing) and Supplier Price Downloads (the
inbound half), whose rules this rig exists to make testable and neither of which is restated here.
| His words | What it fixes |
|---|---|
| "I think we need to create some kind of simple testing system wherein an app contains test data, similar to what a wholesaler would hold" | The rig is an app of ours holding wholesaler-shaped test data — not a mock page, not a fixture folder |
| "we need 3/4 suppliers to use as part of testing environment and each one has its own portal in which we seed data" | Three to four portals, four suppliers, four seeds — one supplier per portal |
| "they can be made as simple and basic as necessary as they're only purpose is for testing" · "they will never be used in production" | Minimum surface. No styling, no branding, no roles, no MFA, no production anything. Production is not a target and this must be enforceable, not merely intended (§7) |
| "each will of course require their own database so data can be seeded" | One database per portal, so no seed can bleed into another portal |
| "Maybe its not necessary to install the test portal on different IP locations - that will make our testing easier" | One host, four portals. The multi-IP spread is dropped; distinct origins are still needed, and that is satisfied by four host names on one host (§6 R1) |
| "one thing we need to bear in mind in terms of data are discounts, which wholesalers are forever changing, with particular emphasis on short-liners" | Discounts are a first-class dimension of the seed data, in the two shapes a file can actually take, plus churn (§5) |
| "anyone in the network (veripath) can access the rig" (2026-09-30) | Reachability: one host on our own network. Anyone inside the VeriPath network may open it; nothing of ours is public (§6 R1) |
| "may as well get all 4 done" (2026-09-30) | All four portals in the first build — no split, and the two discount shapes are settled in the pass that needs them |
| "don't bother with a scenario where there is no portal - I don't [sic] that scene is possible in reality" (2026-09-30) | RULED: the no-portal case is not built. The rig assumes a portal exists in the chain; the manual upload stays the app's answer for anything outside the routes below (§4.1) |
| "we will go ahead and host them" · "I will add 4 subdomains 'portal1', 'portal2', 'portal3' and 'portal4'" (2026-09-30) | HOSTED, and on plain subdomains of our own domain. The internal-only rule is withdrawn (§11.1, §12): a rig behind a VPN nobody connects to could not be reached from the machine the Windows pass runs on, and the satellite's real target is a public portal over TLS. His reasoning, recorded: "I don't think anyone is going to mistake them for actual wholesalers considering we will not be adding any content" |
The one thing this rig does not settle. Whether the app reads a discount column, or derives a discount, or shows
a net figure against a tariff, is app-side work and is unspecified. The rig's job is to put files in front of the app
that a real wholesaler could plausibly produce, so that whatever is specified later is tested against something honest.
Nothing in the rig may define that feature by accident (§7).
| Thing | Today | Where |
|---|---|---|
| The four synthetic suppliers | Live on the client tenant with portal configuration, evidence lines and DEMO markers: Synthetic Wholesaler (DEMO), Two, Three, Four — 6 suppliers, 2 of them configured |
seed_pharmacy_stock.py, the supplier list |
| The portal record | Supplier.portal_method = NONE / FILE_DROP / GRID, portal_target, the date seen, what was seen, portal_is_demo |
stage-1 spec |
| The signal | #veripath-probe-signal, SIGNAL_CONTRACT = 2: contract, status, reason, order, supplier, method, portal_target, file, exported, demo. Inert, escaped, no credential |
stage-1 spec §5 |
| The satellite | Read-only, one CDP call (Runtime.evaluate), prints six labelled lines and stops. satellite/ in the app repo: browser.py, signal.py, downloads.py, report.py, run-satellite.bat, VERSION |
stage-2 spec |
| The import | Upload → per-supplier column mapping (hints PIP_CODE / DESCRIPTION / PACK_SIZE / TRADE_PRICE) → confirm → rows stored against supplier and price date. Headered or operator-declared headerless; decimal-comma files are refused |
The File Contract |
| Synthetic price files | Four supplier example files already committed, plus the awkward shapes (synthetic-portal-export-five-column.csv, synthetic-renamed-headers-decoys.tsv, synthetic-no-header-positional.csv, a semicolon/decimal-comma file) |
repo |
| No discount anywhere | There is no discount column, no list-vs-net concept in the import, and the margin column was deliberately not built (phase 4 W4: a margin would be a fabricated price against a real tariff) | phase-4 spec |
Nothing in the app knows about a portal's page layout, and nothing may. The satellite holds no selector, no address
and no tenant hostname: everything it acts on arrives on our own page, inside the tenant (stage-2 §5 W1). The rig
therefore has to be reachable by a browser, not known by the program.
One small app of ours, in its own directory, outside the product repository, in its own container, serving
four portals on four host names, each with its own database and its own seed. Each portal is a plain page
carrying the controls a real wholesaler's portal carries — no more than the satellite and the operator need.
Four portals rather than one portal four times, and this is the whole design argument: the satellite's behaviour is
configured per supplier, so if the four portals share one template the configuration is never actually exercised. The
four pages must therefore genuinely differ in their markup and in the name of the control each one exposes —
which control is found, and what it is called, is exactly what the per-supplier configuration has to get right.
The rule of thumb: minimum surface, maximum difference. Everything may be as basic as it likes — plain HTML, no CSS
worth the name, no images, no navigation — except the two things the test is about: the file must be produced by a
real browser download from a real control on a real page, and the four pages must be different enough to matter.
Each portal answers one question the programme cannot answer today. The supplier names are the existing demo records,
so the supplier list, the price files and the portals all speak one vocabulary rather than three.
| Supplier (existing record) | Its method today | The portal page | The price file it emits | |
|---|---|---|---|---|
| P1 | Synthetic Wholesaler (DEMO) | FILE_DROP |
a bulk-upload input (outbound stage 3) and a download control | headered CSV — a prices column and a discount column, i.e. the file states both |
| P2 | Synthetic Wholesaler Two (DEMO) | GRID |
a quick-order grid (outbound stage 4) and a download control | headered CSV — one price column already net, nothing about a discount |
| P3 | Synthetic Wholesaler Three (DEMO) | FILE_DROP |
a download control only, plus a second one that produces two files at once | headerless (the app's declared-headerless path), and its figures change between two pulls |
| P4 | Synthetic Wholesaler Four (DEMO) | NONE |
an upload input, the prices displayed as a plain table, and nothing to download | none — the absent-download case, with three reachable states (§4.1) |
Why P3 carries two controls. Inbound stage I3's catch rule accepts exactly one new file and refuses two or none;
that refusal cannot be proven without a control that produces two. Why P4 has no download at all. Matthew's own words
in the scoping note — "I cannot confirm that every single wholesaler has a download facility" — make the absence a
finding that must be reported, not a silent failure. P4 is the only way to prove it.
The No CSV download facility section Matthew added to
Discounts & Test Portals on 2026-09-30 raises
this case directly, and it is worth separating what is real in it from what is not.
Real, and why P4 exists. A supplier may have no download control at all. Matthew's own words in the scoping note
— "I cannot confirm that every single wholesaler has a download facility" — make that a finding the app must report,
never a silent failure, and P4 is the only thing that can prove it. Also real, and a separate case: a portal with no
download that nonetheless shows the prices on screen, as a table. A rig built only for the first case would quietly
answer the second one by accident, so P4 carries three reachable states and a seed that switches between them:
Not adopted, and it must not be built into the rig. The section's answer to case 1 is to read the table off the page
and write it straight into the database. That breaks three standing rules at once: the app is the only writer, the
satellite never writes to a database or holds a database credential, and reading the portal back into our records
is deliberately out (sibling note, deliberately not in this stage). Its claim that the wholesaler "cannot block" this,
and that "if a human can read it, we can read it", is not a technical or a legal position we may rely on — a portal can
render a grid in ways a single read misses, and the wholesaler's own terms govern whether automated extraction is
permitted at all. Its only citation is a wholesaler's own marketing page, which under our evidence rule is **not
And the list of routes is not claimed to be complete — this is the rig's standing rule, in Matthew's words
(2026-09-30): we do not know for certain what each of these wholesalers use for circulating their price lists to
buyers, so we need to consider any possible way. The routes known today, and each one's home in the rig:
| Route a price list can arrive by | Its home |
|---|---|
| A download control on the portal | P1–P3 |
| The prices shown on screen, nothing to download | §4.1 state 1 |
| A print/PDF view | §4.1 state 3 |
Ruled out 2026-09-30: there is no no-portal scenario. A price list arriving with no portal in the chain at all
(emailed to the buyer) is not built, on Matthew's judgement that it does not happen in reality. Nothing is lost by
that: the manual upload remains the app's answer for anything the satellite is not the right tool for (invariant 1),
and it is covered by the existing import, not by the rig.
Anything that arrives outside the routes above is a finding to record and answer, not a gap to improvise around at a
pilot.
evidence**. What the rig owes this case is P4's three states, not a scraper: the app reports the absence, the manual
route carries the file, and if a third method is ever wanted it is an app-side decision taken against a portal we have
actually seen, with the date and who saw it recorded.
The Gemini exchange on Discounts & Test Portals
carries two positions that cannot both be the default for the same supplier: that a short-liner's file gives baseline
prices and we should layer a discount matrix on top, and that a short-liner's file is already pre-discounted net. So
the seed data should not pretend to know: it should carry both shapes, and one portal whose figures move.
Only two file shapes exist, and they are not interchangeable:
The third thing Matthew named is not a shape but a behaviour: churn. Discounts change, especially at short-liners,
and often without the portal's menus changing. So P3's figures move between two pulls: one control serves the file as
it stands, and the seed's churn control tightens or withdraws a discount. That is what makes the app's existing
freshness, supersede and price-date behaviour testable against a real second file, and it is the only way to test the
difference between what the file says now and what we last knew.
Where the discount line is drawn, stated plainly so the rig does not drift into the product: the rig produces files
and nothing else. It does not compute a discount, does not hold a discount rule, does not validate an invoice and does not
know what the pharmacy was charged. The discount matrix, the invoice back-check and the "best offer before you order"
comparison all live in the app, are unspecified, and are not this rig's work (§7).
R1 — the rig itself, its own host names, its own databases. A small app in its own directory, outside the product
repository, in its own container: four reserved, synthetic host names (one per portal — distinct origins are what
the allow-list check needs, and Matthew has dropped the multi-IP spread), four small databases, one seed loader, one
start/stop script. Amended 2026-09-30 (Matthew): HOSTED, on four plain subdomains of our own domain — portal1 to
portal4.veripath.co.uk, one per portal, fronted by the platform's existing nginx with a Let's Encrypt certificate
and the plain-HTTP form redirected. The original rule was internal only, no public DNS record, and it failed on contact
with reality: no machine that would run the satellite was on either internal VPN, so the rig could not be reached at all,
and the satellite's real target is a public portal over TLS anyway. The rig's own port remains bound to loopback and
the two internal addresses and is never bound publicly; the vhost is the only way in, and it passes the name through,
because the rig serves a portal by the name it is asked for. The impostor name stays outside our domain, or R5's
refusal could not be proved. Deletable in one command, with no trace in the
product repository. No real wholesaler's name,
branding, layout or address appears anywhere in it; the recorded portal targets are placeholder addresses on our own
reserved names, never a real portal and never an internal host of ours.
R2 — the four portals (§4), all of them in the first build. Four plain pages, each with its own control names, each with the controls listed in the
table. P4 deliberately has nothing to download.
R3 — the seeds. Per portal: a small catalogue of synthetic medicines reusing the demo items' vocabulary, with
figures set so the same item appears at different prices across the portals (otherwise no comparison is testable). One
seed file per portal, reloadable, so a wrecked portal is restored without touching the other three. Dummy data only —
no real patient, prescriber or pharmacy data of any kind.
R4 — a login on two of the four, and an open page on the other two. One plain form, one dummy account, one logout
link. Not for realism, for two states we cannot otherwise reach: the signed-out tab and the session that ends
mid-run, where the satellite must notice and report rather than half-drive a login page. What the login must also prove
is on the record: the satellite touches no cookie and transmits no credential. The dummy credentials live in the
rig's own notes and never in the wiki — the same rule as any other credential.
R5 — the impostor page. A copy of a valid #veripath-probe-signal block served from an origin not on the
allow-list, so the stage-2 debt can actually be paid and proven: before the satellite may act on a page, it must
refuse a page that merely imitates our signal. The allow-list itself is app-side work (it is owed by stage 2, §5 W3, and
this rig is the only thing that can prove it); the impostor is the rig's contribution to it, and the two should be
built in one pass so neither is unverifiable.
R6 — the churn control. A way to change P3's figures (and to serve a discount as "withdrawn" or "tightened") on
demand, so a freshness/supersede test is repeatable while someone is watching, plus a seed flag for the same change
from cold.
R7 — the rig notes. One short page inside the rig — how to start it, how to reset a seed, how to run a test, and
the dummy logins. Not a wiki page, and not part of the product's documentation.
INSTALLED_APPS, not in a tenant'snoindex, the content is invented and there is no real data, no realRig-side (builder's): each portal's routes answer; each control produces exactly the file shape and file count
its row promises; a seeded catalogue is reloadable per portal without touching the others; the two-file control really
produces two files; P4 has no download control at all; the impostor page carries a signal that is valid in every
respect except its origin.
Live, by me, on the Linux rig, and not taken on report (the stage-2 pattern: a real Chrome, the real launcher, and
the expectations read read-only out of the rig's own databases): for each portal, a full run — the satellite finds
our page, reads the signal, takes the file, prints the report and exits, with the exit codes and sentences stage 2 §6
fixes; P3's two-file control refused in the app's own words; P4's absence reported as a finding, not a silent
failure; the impostor origin refused once the allow-list lands; a file from P2 and P3 imported into the app so
the freshness, supersede and price-date behaviour is exercised by a real second file; P1's list-plus-discount file
mapped without the app inventing a discount; the full pharmacy suite still green; and the rig deleted at the end
of the pass to prove nothing in the product depended on it.
This rig is the precondition for outbound stages 3 and 4 and inbound I2–I3 — nothing else can verify them. Two items
should land before the first rig run, or every run is noisy:
from-hermes (2026-09-29): run-satellite.bat runs the program| # | Question | Answer |
|---|---|---|
| 1 | Who can reach the rig? | "anyone in the network (veripath) can access the rig" — superseded 2026-09-30: hosted publicly on four subdomains of our domain, because no machine that would run the satellite was on the internal VPNs (§11.1) |
| 2 | All four portals, or two to start? | "may as well get all 4 done" — all four in the first build |
| 3 | The login on two of the four? | Answered by my recommendation, which stands (not put to him): keep it on two, because the signed-out and expired-session states cannot be reached any other way |
| 4 | Throwaway, or the platform's future demo environment? | Answered by my recommendation: a test rig first, built to the standard of "minimum surface, maximum difference" and therefore showable inside the network without embarrassment — no effort spent on demo polish, and the demo question is left open rather than settled |
| 5 | The no-portal scenario (raised by him, then ruled) | "don't bother with a scenario where there is no portal" — not built (§4.1). The manual upload covers anything the satellite is the wrong tool for |
OpenCode read the go-ahead, asked its two questions and built nothing — ask, do not guess. Both are answered
here; neither needs Matthew, and both are recorded rather than left in an inbox.
VPS1, at /opt/synthetic-portal-rig, as its own compose stack with its own Docker network — deliberately NOT
the hub network (gp_booking_app_gp_booking_network), so the rig can see nothing of the platform, and nothing of the
platform can see it. Outside the product repository, nothing in INSTALLED_APPS, and no import of any product module
at all — a shared model would end "deletable in one command". Four names (p1–p4 under a rig label of our own
domain), and no public DNS record for any of them: resolution is an internal resolver record if one exists, otherwise
plain host entries on the machines that need them — the dev rig, and Matthew's PC for the Windows pass. (That is a rig
gotcha, not a product one, and it belongs in the rig's own notes.) If no internal resolver exists at all, one name with
four ports is an acceptable substitute: an origin is scheme + host + port, so the four origins still genuinely differ.
noindex on every page, a plain synthetic banner, and nothing published beyond the internal interface.
A dedicated Postgres container inside the rig's own stack — not databases on the central instance. Two reasons:
the rig must be deletable in one command with no trace, and the central Postgres holds the product's data. A test rig
does not share it. One database per portal, as R1 says.
OpenCode's option A stands: a domain-suffix allow-list compiled into satellite/. Option B — an explicit origin list
— is refused, because it would name a tenant host inside the program, which stage-2 §5 W1 forbids. Four
requirements on the mechanism, so that having it is worth something:
. + the domain — never a plain stringevil-veripath.co.uk and veripath.co.uk.evil.test are the traps, and each gets its own test.127.0.0.1 / localhost only, and says so in the code.Status: BUILT, DEPLOYED AND VERIFIED LIVE. OpenCode built it
(pharmacy_booking_app feature/pharmacy-satellite-allowlist @ 09058e3, origin/dev and the deployed
revision confirmed by me on the app host; no migration) and I verified it the same session. Nothing here is taken
on report: every expectation was read back out of the running rig (its own databases and its own HTTP responses), out
of the live tenant, or out of the deployed program's own output.
The numbers, so the pass never has to be redone. Full pharmacy suite 600 tests, OK (233.8 s) run by me on the
deployed revision. verify-satellite-view-live.py 97 checks, all passed (it was 96; the new one is the domain
check below). verify-satellite-skeleton-live.py all checks passed. verify-satellite-view-admin-host.py
9 host-side checks, all passed (the platform administrator's read-only copy). New this session:
verify-synthetic-rig-live.py 88 checks (isolation, the four portals and their files, seeds and churn, the mapping
hints), verify-synthetic-rig-browser-live.py 22 checks (the allow-list with the program itself, and the portals
driven as real browser controls), verify-rig-files-into-app-live.py 16 checks (the rig's files at the live app's
own mapping step).
A — the launcher's order of steps. Read in source and shipped: run-satellite.bat now waits after the port answers,
tells the user to log in and open the order, and runs the program on a keypress with no timer; a test pins the pause
before the run. What proves the packaged file is the fixed one is §12's zip check below (every packaged program file
byte-identical to the repository's). The Linux equivalent then ran the program after the port answered and produced
the real order, file and line count. The Windows proof stays Matthew's pass — a .bat cannot be exercised here.
B — the seed's blast radius. seed_pharmacy_prices --help, read off the deployed app: the --reset line names what
it removes ("EVERY price file belonging to the four synthetic suppliers … so it deletes imported files too").
C — the origin allow-list, with the program itself (stage-2 §5 W3, paid). One page, a valid signal in every respect,
served three ways: from the impostor's origin the deployed satellite refuses it, exit 8, naming the origin, saying
"is not a VeriPath page" and "Nothing has been read from it", and reporting no order; the same page on a
loopback origin is read and produces the real report; a file:// copy is refused. Label-boundary matching, the
HTTPS rule and the loopback exception are as §11.3 required, and the installer that ships is checked for what it now
carries: the domain appears in satellite/origins.py and in no other member, while no member carries the tenant
hostname, the tenant slug, a supplier name, a portal target or a credential — the intent of W4's rule, kept, with the
one deliberate change stated rather than quietly allowed.
D — the rig, on its own terms (R1–R7). Its own directory outside the product repository, no product module and no
Django imported anywhere in it, its own Docker network — verified not the hub network, and no rig container on the
hub — its own Postgres, one database per portal (four plus the rig's), published on loopback and the two internal
addresses only and never 0.0.0.0, noindex and a synthetic banner on every page. Its own tests: 11, OK. The
four portals do what §4 says: P1 a price and a discount column behind a login, P2 one net price, no login,
a quick-order grid, P3 headerless with a second control, P4 no download in two of its three states and a
print/PDF in the third. The same item is priced differently in all four. The churn control moved P3's figures and
nothing else's. R4 in a real browser: signed out there is no download control in the DOM and the sign-in form is
what is shown; signing in is a real form post. R6, the trap that mattered most: P3's second control put two files
in the folder — two separate downloads, not a zip, not one — in headless Chrome for Testing.
The join, which is what the rig is for. The three files the rig emits were fetched from the running rig and posted to
the live tenant's own import form as the Business Manager: each reaches the mapping step with the file's own first
line shown; P1's discount column matches none of the app's four mapping hints, so there is nothing for the app to
apply it with (the point of §5's separation); P3's headerless file is offered the headerless choice and its first line
is shown as a row, not eaten as a header. Nothing was written: the price-file list was identical before and after.
Two findings, and both are about the pass rather than the build. (1) Three of my own verifier expectations were
stale the moment the allow-list landed — the sheet now quotes nine report states, not eight; the domain is now
carried, deliberately, by origins.py; and saved pages must be served over loopback http, because a file:// page
is refused by design. I corrected the verifier rather than the product, and the corrections are recorded here so the next
pass does not re-derive them. (2) delete.sh was a one-command delete with no way back — it removes the rig's
directory, and the rig's source existed only on the host, in no version control. A test rig that stages 3–4 and
I2–I3 must both use is not disposable if it cannot be rebuilt, so the source is now committed as
veripath-ops/rig/synthetic-portal-rig/ with a RESTORE.md saying how to put it back.
Hosted 2026-09-30, after the pass above, on Matthew's instruction. The four names are
portal1–portal4.veripath.co.uk, nginx in front of the rig's own internal port, TLS issued with Let's Encrypt, one certificate covering all
four names (the first issue covered portal1 and portal4, the only two in DNS at the time; portal2 and portal3
were added the same day and the certificate re-issued with --expand, the lineage name unchanged). Verified: HTTPS 200
on each of the four, the plain-HTTP form 301s to HTTPS, and the certificate validates against each name. The rig's port
is still internal-only.
The delete was run, and then the rig was rebuilt from that copy. scripts/delete.sh removed the containers, the
volume, the network and the directory in one command; with no rig present, the product's own end-to-end satellite
check passed in full — which is the independence R1 promised; and scripts/start.sh then rebuilt and reseeded the
rig from the versioned copy, after which the whole rig verifier passed again (88 checks). The rig is left running
for Matthew, with its host entries on this host only.
What this pass does not prove, said plainly. The satellite driving a portal (outbound stages 3–4) and the
inbound catch and hand-over (I2–I3) are not built, so the drives they perform on these portals cannot be run yet.
This section records the rig as ready for them, not those stages as done. The Windows pass (stage-2 §5 W6) remains
Matthew's and still owns the packaging decision.
Footprint. Writes: the rig's own databases (its own container), and nothing else — no product database, no tenant
configuration, no order created, cancelled or exported, no price file imported (the price-file list was proved unchanged),
no portal contacted, no credential entered anywhere. The tenant's satellite switch was left exactly as found.
Found before it bit, and fixed the same day — Matthew's go-ahead the same session. The rig's files carried item codes
of its own invention (AMOX500, AMOX250, BRUF400, PARA500, SALB100, OMEP20, METO500, ATEN50). The app's
stock items answer to entirely different codes — PIP codes (0123456 Amoxicillin 500mg capsules, 2345678 Ibuprofen
400mg tablets, 5678901 Aciclovir 400mg, 6789012 Methylphenidate 10mg MR, 7890123 Brivaracetam 25mg, 8901234
Typhoid vaccine, 5670001 the synthetic four-supplier item) or, where an item has no PIP code, a barcode
(5000124439750 Salbutamol 100mcg inhaler). The import matches the code column against pip_code, then barcode, and
never fuzzily — so every rig file would have read and mapped cleanly and then matched nothing: 8 rows read, 0 matched,
no price on any item. The join had been proved only as far as the app's mapping step (the 16-check verifier below), which
is exactly why the mismatch survived that far.
The change: the rig's catalogue keeps its eight items and its four different prices per portal, but now emits the
app's own item codes (its PIP codes, plus the barcode for the item that has no PIP code) — which is what a wholesaler's
price file carries. Same file shapes, same seeds, same churn. veripath-ops c226b73; the rig's source of record is
rig/synthetic-portal-rig/, the running rig was rebuilt and reseeded from it, the two verifier constants that named the
old first code were updated with it, and the rig's README now records the rule (keep the codes answerable to items that
exist in the app).
Verified (all run by me, 2026-09-30): the rig's own tests 11, OK; verify-synthetic-rig-live.py 88 PASS;
verify-synthetic-rig-browser-live.py 22 PASS; verify-rig-files-into-app-live.py 16 PASS. Then the point of
the change, against the live tenant, read-only, using the app's own matcher (_ItemMatcher) over P1's and P3's real
files fetched signed-in through the portals' own forms: P1 8 of 8 rows matched, P3 8 of 8 rows matched — 16 of 16,
including the leading-zero PIP and the barcode-only item. No import was committed and the tenant's price files are
unchanged (six). The test-environment page's pictures were re-captured and replaced, because they show the portals'
tables.
Still not decided, and not decided by this: what a discount in a file means. P1's file states one; the app has no
field to apply it with (§5). The change makes the rows land — nothing more.