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

  1. 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

  2. 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

  3. 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

  4. 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-recovery

    wiki139/logs/rebuildData-20260909-015400.log

  5. 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

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

  1. 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

  2. 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

  3. 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 LOCKED

    packages/db/src/enquiries.ts:108

  4. 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

  5. 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.