Digisky
Steel and iron trade2026 · 39 commits over roughly 26 hours

A public fraud registry, built in 26 hours

From empty repository to a deployed public registry with an approval workflow, an append-only audit trail and 1,360 test declarations, in about 26 hours.

The problem

Members of a trade sector were repeatedly defrauded by the same counterparties, and each learned about it privately and too late. The requirement was a public, searchable registry of reported fraud that anyone can read without an account and only vetted members can write to — with enough accountability built into the write path that it does not become a defamation instrument.

52,812 · 286Lines of code · files
~26 hoursElapsed build time
15 · 8 · 6HTTP endpoints · tables · migrations
707 backend + 653 frontendTest declarations
9 routes · 17 components · 182 Persian labelsFrontend surface
15 of 15Planned units of work completed green

What a public accusation registry has to get right

The product is one page: a searchable list of companies that members of a trade sector have reported for fraud. Anyone can read it without an account. Only vetted, admin-approved members can add to it.

The engineering interest is not in the list. It is in the fact that a public register of accusations is a legal and ethical instrument, and almost every convenient shortcut in building one makes it worse.

"If someone puts my company on this list and it isn't true, what happens?"

That question has to have an answer before the first row is written. Here, the answer has four parts: the reporter is vetted before they can write, the reporter’s name and company are shown publicly beside the report, the report is frozen so its history cannot be quietly rewritten, and a takedown path exists as a status in the database. The last of those is only half built, which is stated below and in the limitations rather than left to be found.

The search is ILIKE, and that is the decision

The alternatives were written down and rejected in the plan before the code was written:

Option Why not
Full-text search with tsvector / tsquery Needs a text-search configuration and a dictionary; Persian stemming is weak enough that it buys worse results in exchange for an index, a migration and a configuration file
Trigram similarity with pg_trgm Aimed at typo tolerance over large lists — a problem this registry does not have yet, and one that costs a GIN index migration to solve early
Substring match with ILIKE Chosen

Persian and Latin digit forms fold through the same shared utility on the read path and the write path, so a company registered with Persian digits is found by a search typed in Latin ones, and an end-to-end test asserts it in both directions.

There is no model, no embedding, no vector store and no ranking function anywhere in this product. On a registry whose output can damage a company’s ability to trade, a match a reader cannot verify by eye is not an improvement.

The AI story here is entirely about how the thing was built — and it is a good story, which is why it does not need to be inflated into a product claim.

26 hours, 39 commits, from an empty repository

Lines of code · files 52,812 · 286
Backend test-to-production ratio ~3.2 : 1
HTTP endpoints · tables · migrations · enums 15 · 8 · 6 · 8
Frontend routes · components · Persian labels 9 · 17 · 182
Test declarations 707 backend + 653 frontend
Commits · elapsed 39 · ~26 hours
Planned units of work green 15 of 15, in about 8h20m

The fifteen plans ran in seven dependency-ordered waves, each backend task against its own isolated database so concurrent work could not interfere, with an integration check on the merged tree after every wave.

The design system was built and approved before any application code existed, and it gated the screen work. The Persian labels in the running application are copied verbatim from the design mockups and the browser tests match those exact strings — so the application and the design cannot drift apart without a test going red.

Two pieces of the security work worth naming

Refresh-token replay detection with family revocation. Tokens are stored as hashes keyed by identifier and rotate on every refresh. Presenting an already-rotated token is treated as theft rather than as a mistake, and revokes the user’s entire token family.

The interesting part is the bug that review caught: the revocation was being rolled back by the transaction that returned the 401. The system correctly detected the replay, correctly revoked the family, and then correctly undid all of it while returning the error — so the detection worked and had no effect. That is the kind of defect that ships happily, passes a manual test, and only shows up when someone reads the transaction boundaries rather than the logic.

Authorisation reads the live user, not the token claim. Guards reload the user record rather than trusting the role and state embedded in the token, so suspending a member takes effect on their next request instead of whenever their token happens to expire.

What is not finished

Three open gaps, stated plainly

TLS is not wired into the containerised deployment path; the stack listens on plain HTTP at its edge and expects a terminator in front of it.

The takedown queue is designed and not built. There is a rejected status in the database and no interface that drives it. On a public accusation registry this is the most material gap in the system.

Rate limiting lives in process memory, which is correct on one replica and wrong on two.

All three are recorded in the repository's own documentation. Leaving them off a public case study would be a choice to conceal something the team already wrote down.

Decisions

Search is a substring match with digit folding. Nothing more.

Alternatives: PostgreSQL full-text search with tsvector and tsquery, Trigram similarity with pg_trgm, Semantic search over an embedding index

Full-text search needs a language configuration and a dictionary, and stemming for Persian is weak enough that it would have added an index, a migration and a configuration file in exchange for worse results. Trigram similarity is aimed at typo tolerance across large lists, which is a problem this registry does not have yet. Semantic search would have answered a question nobody asked: a member searching a company name wants the rows containing what they typed, in a form they can verify by reading. The whole search path is one predicate, and it is honest about what it does.

Persian and Latin digit forms are folded through the same shared utility on both the read and the write path, so a query typed in either digit script finds the same company.

No AI in the product. At all.

Alternatives: Semantic matching of company names, Automated classification of report text, An assistant over the registry

This is a public accusation registry. Every match a reader sees has to be one they can check by eye, and every consequence has to trace to a named member who filed it. A ranking function nobody can explain is the wrong tool for that, and a model that infers a link between two companies would be manufacturing accusations. The AI in this project is entirely in how it was built, and claiming otherwise would be both false and a waste of the better story.

Reports publish immediately, with no pre-publication approval gate.

Alternatives: Moderate every report before it becomes visible

A registry that lags reality by a review queue is not useful at the moment it matters, and the accountability model is the vetted-member gate on the write side rather than a reviewer on the read side. The visibility rule lives in exactly one predicate — visible if not deleted and not rejected, deliberately not if approved — so reversing the policy is a one-line change rather than an audit of every query. The corresponding cost is the takedown queue, which is designed and not built, and is listed below as the gap it is.

The registry is append-only. There is no edit.

Alternatives: Allow members to correct their own submissions

A report, including its fraud type, is frozen once filed. A correction is a new submission that stands beside the original. Editing the historical payload would let the registry's history be rewritten quietly, which on a public accusation record is the failure mode that matters most.

Email through a PostgreSQL transactional outbox, not a message broker.

Alternatives: Redis with a job queue, Send the email inline in the request

A notification row is written in the same transaction as the state change it describes, so an email can never be sent for a transition that rolled back nor skipped for one that committed. A broker reintroduces exactly that split, and adds an operational component to a system whose email volume is a handful of messages a day. The dispatcher claims rows with a skip-locked select, renders outside the lock, retries with a recorded attempt count, and dead-letters loudly rather than losing quietly.

The password-reset email deliberately does not use the outbox — persisting the message would mean persisting the one-time token, defeating the reason the token is only ever stored hashed.

Limitations

  • TLS is not wired into the containerised deployment path. The compose stack listens on plain HTTP at its edge and expects a TLS terminator in front of it. This is recorded in the deployment notes as an open seam, and it is the first thing to close.
  • The takedown and moderation queue is designed in detail and not built. There is no takedown interface today, only a database-level rejected status. On a public accusation registry that is the most material gap in the system, and we would rather say so than let you discover it.
  • Rate limiting is held in process memory, so it is correct on a single replica and wrong the moment there are two. The swap to a shared store is a documented seam, not a completed piece of work.
  • Every company registration identifier is free text typed by the member who filed the report. It is normalised and used to deduplicate companies, and it is never validated against any registry. The system does not know whether a company exists.
  • All data is member-submitted. There is no importer, no crawler, no external data feed and no seeded content. The registry knows what its members have told it and nothing else.
  • Evidence is links only. The data model carries file evidence with content type and size, and the storage backend behind it is not wired up.
  • Search finds substrings. It does not correct typos, does not stem, does not rank by relevance, and will not find a company whose name was entered differently from the way you searched for it.