The stack
A wiki that could not be saved, saved layer by layer
2025 to 2026 · about 400 hours · more than 20 technical reports
First proven possible, on a copy, and only then a quote.
The same cross-sections, as text
What each layer does, and what happened there
atlasmuseum
MediaWiki
the foundation
1.26.4 to 1.39.10, in three steps
The software under a public atlas of public art in France, for an association supported by the French Ministry of Culture.
The problem
The live site had been taken over by pirate accounts and ran a 2015 version with no security updates. An earlier migration had been abandoned halfway, leaving mixed and missing tables. More than one party said it could not be saved.
The solution
First a free feasibility test on a local copy, and only then a quote. Then upward in steps: 1.31, 1.35 and 1.39.10. Tens of thousands of spam pages and accounts removed, the deletion list checked three times.
For developers
MediaWiki 1.26.4 to 1.31 to 1.35 to 1.39.10, with the actor model, the Multi Content Revision tables and the 1.39 extension manifests on the way.
source: atlasmuseum delivery document
PHP 8.3
the engine
7.4 to 8.3.36 · 43 extensions along
The language the wiki and all its extensions are written in. From version 7.4 to 8.3: a new engine in a car that keeps driving.
The problem
The collection page no longer worked at all under PHP 8: arguments in the wrong order, pagination that could never advance, and one invalid request that crashed the whole call. The map generator had an SQL injection: input from outside could be read by the database as a command.
The solution
The collection page repaired line by line. The hole closed with a prepared statement, so input is never read as a command again. Since October 2026 the engine runs in its own container on my own server, rebuilt from scratch in three minutes with every security update.
For developers
collection.php: join() with its arguments reversed, array_merge() on an object result, a pagination ceiling of 4,000 under a page size of 5,000. map.php::getMap(): SQL injection through the nature parameter. Image on php:8.3-fpm-bookworm with mysqli, intl, gd, zip, calendar, exif, opcache and APCu 5.1.28, rebuilt with --pull --no-cache. The version at the top is read live from the api.php of the wiki every ten minutes.
$stmt->bind_param(str_repeat('s', sizeof($nature)), ...array_values($nature));AMAPI, map.php
MySQL 8
the archive
5.7 to 8.0, repaired in place
Everything lives here: every version of every page. Starting over would have cost the whole history.
The problem
The database was full of damage from the abandoned migration: duplicate revision numbers, more than 8,000 rows on a single revision alone, a missing index and half tables from newer versions.
The solution
Repaired in place, not rebuilt. More than 8,000 rows renumbered, the index and the tables completed, so the full history was kept.
For developers
Duplicate archive revision ids renumbered (revision 149315), missing revision timestamp index, actor migration, MCR tables, repairs in change tags, logging, filearchive and user talk.
source: atlasmuseum delivery document
Semantic MediaWiki
the catalogue
51,277 entities, 0 skipped
The layer that knows which artist made which work and where it stands. The atlas searches and filters with it.
The problem
One component was not loaded. As a result, 4,589 pages listed as their artist not the artist, but the name of a variable from the code.
The solution
The cause found, the component loaded and the whole catalogue rebuilt: 51,277 entities in nine and a half minutes, none skipped, ending with exit=0.
For developers
PageForms was not loaded, so #arraymap never expanded and SMW stored the loop variable (Auteur=Artist) instead of the value. rebuildData.php under SMW 5.1.0.
$ php extensions/SemanticMediaWiki/maintenance/rebuildData.php --auto-recoverywiki139/logs/rebuildData-20260909-015400.log
Two-level cache
the speed
26 to 64 times faster
What has been looked up once is remembered. A visitor never waits again for something the system already knows.
The problem
The site called itself through a detour, and outside sources such as Wikidata were queried without a cache. Pages were slow as a result.
The solution
The detour removed, a two-level cache around the outside calls, the page cache switched on with an epoch for targeted refreshes, and images that load only when they come into view. Repeat page loads measured 26 to 64 times faster.
For developers
In-request memoisation plus an on-disk cache with a thirty minute TTL around the upstream API and SPARQL; parser and page cache with a cache epoch; server-to-self loopback removed; galleries behind an IntersectionObserver.
source: atlasmuseum delivery document
Funeral SaaS
Next.js and React
24 screens
4,000 files: 2,100 to 40 ms
Everything a funeral director works in: the case file with seven tabs, a week calendar, transport, the run sheet for the day of the funeral, purchasing and invoices.
The problem
With 4,000 case files in the system, the list screens took about 2,100 milliseconds.
The solution
Pagination and the right indexes in the database. The same screens now load in about 40 milliseconds, measured on 4,000 case files.
For developers
List screens from about 2,100 ms to about 40 ms on 4,000 case files, through pagination and indexing. Four production dependencies: next, react, react-dom and postgres. No ORM, no CSS framework, no authentication library.
source: handover, September 2026
The law
Dutch burial and cremation act
legal terms in the database
A funeral has to meet strict legal terms. The system guards those terms itself, in the database, instead of relying on whoever makes the schedule.
The problem
The law sets hard terms: not earlier than 36 hours after death, no later than the sixth working day, separate terms for ashes, leave rules, VAT rules, and invoices that may not change once sent.
The solution
Those rules live in the database itself instead of being spread through the application. Every screen, every import and every future integration passes the same rules, in the same place.
For developers
The 36 hour term and the sixth working day from the Wet op de lijkbezorging, leave rules, the article 59 term for ashes, VAT rules and invoice locking, encoded in the database rather than in application code.
source: handover, September 2026
PostgreSQL
plain SQL
46 tables · 119 SQL tests
The data of every funeral director. The database tests itself: 119 tests check that the rules do what they should.
The problem
If the rules live in the database, they have to be tested there. A test in the application proves nothing about a rule that runs in the database.
The solution
119 tests that run directly in the database, over 46 tables and 29 numbered migrations.
For developers
29 SQL migrations, 46 tables, 119 SQL tests, about 19,300 lines of code at handover.
source: handover, September 2026
Row-level security
multi-tenant
FORCE and a RESTRICTIVE role lock
Every funeral director works in the same system, each on their own subdomain. Each has their own vault in the same building.
The problem
In software for many funeral directors, one forgotten filter in a query is enough to show another customer's case files.
The solution
Row-level security on every table, with FORCE and a RESTRICTIVE role lock. A query that forgets its filter gets nothing back instead of someone else's files. The database protects, not the programmer's discipline.
For developers
RLS on every table with FORCE ROW LEVEL SECURITY and a RESTRICTIVE policy on the application role, so a query without a tenant filter returns an empty set.
source: handover, September 2026
Audit log
append only
old and new value, always
Everything that changes in a case file is recorded with what it was and what it became. A mistake can be rolled back.
The problem
In funeral case files every change has to be traceable, and a customer's mistake has to be recoverable.
The solution
An audit log that can only be added to, with the old and the new value of every change. The same log drives the undo feature.
For developers
Append-only audit log with old and new values; the undo feature reads from the same log.
source: handover, September 2026
Data center, Naaldwijk
the hosting
ISO 27001 · ISO 9001 · ISO 14001 · NEN 7510
The servers stand in a Dutch data center whose security is checked every year by an independent party.
The problem
Funeral case files hold personal data of the deceased and their next of kin. A customer wants to know where it lives and who looks after it.
The solution
Launch in a data center in Naaldwijk certified to ISO 27001, ISO 9001, ISO 14001 and NEN 7510. Those certify the data center, not the software itself.
For developers
The data center holds ISO 27001 (information security), ISO 9001 (quality), ISO 14001 (environment), NEN 7510 (information security in healthcare), SOC 1 and SOC 2, and PCI DSS. The software itself separates customers with row-level security in the database.
source: the data center's certifications, fetched 17 October 2026
This site
React
the interface
works without JavaScript too
The screen a visitor types into and clicks. The contact form also works without JavaScript, so nobody is left out.
The problem
On an iPhone the page was 16 pixels wider than the screen. Safari zoomed out, the page slid sideways and a dark strip appeared on the right.
The solution
The cause found in a row of navigation links and a fixed background that stuck out over the edge. Fixed, and the whole site got overflow-x: clip as a safety net, without breaking the sticky scenes.
For developers
The form is a real form with a real action; client state only shows progress. overflow-x: clip rather than hidden, because hidden makes the root a scroll container and breaks position: sticky.
const [submitting, setSubmitting] = useState(false);apps/web/src/components/contact-form.tsx:40
Next.js
App Router
10 pages · 3 API routes
Like the front desk of an office: takes the message, checks it is complete and confirms at once.
The problem
The build on the server failed whenever Google Fonts was briefly unreachable. The site could not be updated because another service was having trouble.
The solution
The typefaces shipped with the site instead of fetched from Google. The build no longer needs an outside service.
For developers
next/font/google replaced by next/font/local with the woff2 files and their OFL licences in the repository, so the build is hermetic.
export async function POST(request: Request): Promise<Response> {apps/web/src/app/api/v1/enquiries/route.ts:55
Node.js
the worker
never the same row twice
Two postmen work at once and never take the same letter. No message is lost and none arrives twice.
The problem
Two workers run at the same time. Without an agreement they can take the same message, or stop halfway and leave it behind.
The solution
Each worker claims a row and leases it for five minutes. If it stops on the way, the row is released again afterwards.
For developers
UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) with a five minute lease on notify_after, in one statement.
FOR UPDATE SKIP LOCKEDpackages/db/src/enquiries.ts:108
PostgreSQL
17 · plain SQL
4 migrations · no ORM
The archive that holds everything. Every change is recorded as a numbered file, like minutes of a meeting.
The problem
If the mail server is down, no enquiry may be lost, and the visitor must not notice.
The solution
The enquiry is stored first; mailing is a separate step after it. If that fails, it is tried again after one minute, each time twice as long up to six hours, at most twelve times, with the error kept on the row.
For developers
A transactional outbox on one table: notify_attempts, notify_after and notify_error, with a backoff that starts at one minute and doubles up to six hours.
ADD COLUMN notify_attempts integer NOT NULL DEFAULT 0,packages/db/migrations/0004_enquiry_notification.sql:12
Hash chain
the evidence
every record tied to the one before
Every measurement of this site gets a seal tied to the one before. Anyone who changes one later breaks the chain, and you can check that in your own browser.
The problem
A number on a website is worth nothing if its owner can quietly change it afterwards.
The solution
Every measurement is hashed together with the hash of the one before. One changed record invalidates every hash after it, at a place you can point to.
For developers
hash = sha256(utf8(prev_hash_hex) || utf8(canonical_json(body))), with canonical JSON per RFC 8785, so the same measurement always gives the same hash.
return Sha256.unsafe(sha256Hex(concatBytes(utf8(prevHash), utf8(canonical))));packages/core/src/chain/hash-chain.ts:88
Marks: Simple Icons (CC0), after the owners' brand pages. The other marks are original work.