EC · ENRIQUE CHYRNIA / RYGUASU.PRT ← Home
RYGUASU.PRTSHEET 00 · THE PRODUCTGREENBAR · 132 COL

00First, the product

A minimarket here keeps its inventory on paper or in a spreadsheet: nobody knows what the goods on the shelf really cost, prices move with the dollar, and the till leaves no history anyone can check. Ryguasu is a subscription per branch with a 14-day trial: the business loads its products — factory barcodes, internal codes or scale PLUs — and sells from a web POS that can take losing the internet.

The backend does the boring, hard part: it recalculates the weighted average cost on every purchase, freezes price, cost and name into every sale line, and handles Guaraní as base with ARS/USD/BRL at each business's own rate. The demo tenant carries 4,551 sales over 183 days, squared against its own reports.

Ryguasu POS: quick-sale screen with a ticket loaded, total in Guaraníes and an offline indicator with queued sales
EXHIBIT A · POS in use, sale in progress click to enlarge
A ticket with products by code and by weight (scale), total in Gs. — and top right, "Offline — 3 sales queued": the till keeps selling without internet.
RYGUASU.PRTCARDS 01–07 · THREAT / DEFENSEGREENBAR · 132 COL

Seven ways to break it

A system like this isn't judged by the days when everything runs fine. I built these cards around the uncomfortable questions — the ones an interviewer would ask me, or reality would — and each one answers with the threat, the defense that exists today and how it was tested. The mistakes along the way are told too, exactly as they happened.

CARD 01/07

What if one business queries another's data?

Threat Proven defense

A bug in a query, or a nightly job that forgot its tenant, returns the inventory and sales of every business. With a shared schema this accident gives no warning: everything works, until a customer sees data that isn't theirs.

Two independent layers: the application resolves the tenant on every request, and underneath, PostgreSQL Row Level Security with FORCE policies and a role without BYPASSRLS. Without context, the database returns zero rows — never someone else's.

SELECT * FROM products — «Almacén San Roque» tenant session
codeproductstockprice Gs.
7891000100Yerba Selecta 1kg3428.500
INT-000041Queso Paraguay (kg)6.4252.000
2000015004PLU 15 · Milanesa (scale)3.8061.900
154 rows · only their business, always
Verified · 42/42 in 403 Verification — a suite authenticates as business A and hits all 42 endpoints with business B's identifiers. My part: both layers. I enabled RLS when the schema had 6 tables; bolting it on later, with 25, would have been much worse.
CARD 02/07

What if two tills sell the last unit at the same time?

Threat Proven defense

Two sales read stock = 1 at the same time, both go through, stock lands at −1 and afterwards nobody knows the true average cost.

SELECT … FOR UPDATE on product and balance, always in the same order — concurrent sales serialize without deadlocks. Every money or stock mutation demands an Idempotency-Key.

v1 · check-then-insert ……… real 2-thread race → 500s [FAIL]
v2 · INSERT … ON CONFLICT DO NOTHING … same race test → 1 sale, 0 errors [ OK ]
1 sale · 0 errors Verification — a race test with two threads against a real Postgres (Testcontainers). My part: the first version returned 500s under the race; I rewrote it and kept that same test as a permanent regression.
CARD 03/07

What if the internet drops mid-shift?

Threat Proven defense

The till stops selling — or worse: it sells, and those sales get lost before reaching the server. And here internet and power cuts just happen; they're not a textbook scenario.

Offline-first POS with a local outbox: sales queue up and sync when the connection comes back. Each device carries a JWT license signed by the backend with its tenant and a grace window based on subscription state — identity comes from the signature, never from client input.

local sale outbox signed sync sync_conflict A stock conflict never produces negative stock: it gets flagged and waits for manual resolution through a dedicated endpoint. A queued sale is never lost to an expired window.
No sale lost Verification — the sync suite: idempotent replay, injected conflicts, expired licenses inside and outside the grace window. My part: the sync protocol, the signed licenses and the conflict policy.
CARD 04/07

What if the dollar moves after the sale?

Threat Proven defense

If history gets recalculated with the new rate or the new price, old margins and reports stop adding up. And a client with bad intentions could send its own total.

The client sends only productId and quantity. The server computes and freezes per line the price, cost, name and exchange rate at the instant of the sale, in numeric(19,4) — never floating point. Changing a price today never rewrites the past.

Immutable history Verification — the demo tenant's reports (183 days, 4,551 sales) close against the frozen lines. My part: all the money is computed on the server; the client never sends amounts.
CARD 05/07

What if it boots with default secrets?

Threat Proven defense

A rushed deploy with example credentials: the system starts, works, and sits open to anyone who has read the repository.

A startup guard aborts the boot on default or missing secrets — before the migrations can run. It came out of an independent security audit: 7 findings, each proven by executing the attack, each closed. On top of that: gitleaks over the full history on every push.

7/7 findings closed Verification — each finding was closed by running the attack again, not by ticking a spreadsheet. My part: the startup guard and closing all 7.
CARD 06/07

What if the server dies tonight?

Threat Proven defense

Losing days of sales from dozens of businesses. I work with an old rule of the trade: a backup that wasn't tested is not a backup. Getting to a real one took me four iterations, and they're documented.

Nightly job: dump → compression → age encryption with a public key (the private one never touches the server) → R2, with checksums recorded in the database. The dump completes or screams: it never ends up silently filtered.

v1 · pg_dump vs forced RLS …… production rejected it (local passed: it was superuser) [FAIL]
v2 · toggle + guaranteed trap … re-forces RLS even on failure · tested with kill −9 [ OK ]
local subsystem harness …… 37/37 scenarios, with injected failures and restore to a local S3 [ OK ]
Completes or screams Verification — the v1 [FAIL] left me a lesson in writing: test with production privileges, not with my developer ones. My part: the whole pipeline, from the cron to the encryption.
CARD 07/07

What if the backup never ran — and nobody noticed?

Threat Proven defense

The scariest case isn't the backup failing: it's the backup quietly not running and nobody finding out. The machine died before reporting, and the "daily backup" hasn't existed for three weeks.

Dead man's switch (Healthchecks.io): the job pings when it ends well and when it ends badly — and the absence of a ping is the alarm. The "died before reporting" case is covered by design.

Restore drill · 2026-07-17 · from R2, cold
4,553 sales 154 products 3 tenants 25/25 RLS tables enabled and forced 0 manual fixes
The drill also exposed that the dumps weren't including their FORCE statements — now every dump appends them itself: the restore is self-contained by construction.
Restore tested · 25/25 Verification — downloaded, checksum verified, decrypted and restored onto a clean Postgres. My part: the switch, the drill and the fix it left behind.
RYGUASU.PRTSHEET · HOW IT'S BUILTGREENBAR · 132 COL

Why those defenses exist

None of the above came out of one inspired night. It came out of working with written specs, decisions recorded with their why, and one fixed rule: nothing reaches production without a human approving it.

  • Spec-driven development · 35 specsI work with SDD: every part of the system is specified before it's coded, and those 35 specs are the source of truth. The written API rules — an endpoint that deviates updates the contract in the same commit. If two specs contradict each other, I escalate it; I don't resolve it silently on my own.
  • 49 decisions with their whyADR-style records: date, status, reasoning, and what supersedes them if they change. Research before code: bonus units ("12+1") were settled by reading IAS 2 — zero code changed, one document written.
  • CI saw what my machine didn'tGitHub Actions runs the tests with Testcontainers against a real Postgres. The Linux runners caught a timestamp-precision bug that never reproduced on macOS — it would have blown up only in production. Going over the rest of the code turned up 4 more cases of the same problem.
  • Human in the loopThe project is developed with AI assistance, under one condition that isn't negotiable: no push, merge or deploy goes out without my confirmation. The whole infrastructure costs about 50 dollars a month.
RYGUASU.PRTSHEET · PROJECT STATUSGREENBAR · 132 COL

What's done, what's written, what's missing

I prefer to separate what already runs in production from what's specified and not yet migrated. Mixing the two would be selling you something that doesn't exist.

[x] Implemented and verified
  • · RLS on 25 tables · 42/42 adversarial
  • · Idempotency and locks under a real race
  • · Encrypted backups + restore drill 25/25
  • · Offline sync with signed licenses
  • · Multi-currency with frozen rates
  • · Manual subscription payments (Phase 9)
  • · Open-registration defense: per-IP rate limit, 30-day trial purge
[~] Specified, migrations pending
  • · Billing with versioned prices and payment snapshots (DEC-032/033/035/037)
  • · Zero Trust over the staff panel — the separate app exists; the deploy behind Access is pending verification
  • · Full sale by weight: PLU and scale barcodes (spec 15)
[ ] Future, written as a rule
  • · A separate staging before touching production with the first paying customer — hard rule, not good intention
  • · Public demo with a seeded tenant
  • · Access for AI agents via MCP (spec 19)
RYGUASU.PRTSHEET · TECHNICAL INVENTORYGREENBAR · 132 COL

Technical inventory

Java 25 · Spring Boot| PostgreSQL · RLS · Flyway| PgBouncer session mode| Server-side sessions · CSRF · RBAC| Fly.io · Cloudflare Pages · R2| Testcontainers · GitHub Actions · gitleaks| age · Healthchecks.io · Brevo