A Change Control module for a Quality Management System. A change is raised, approved, implemented with evidence, approved again and closed — or rejected at either gate and sent back for rework. Every decision is signed with the acting user's own credentials, and every state change is written to an audit trail nothing in the application can amend.
It is built the way validated systems are built, because that is the discipline it comes from: specification first, signed off before any code existed, around one rule — a change with no audit trail is a change that did not happen.
Backend · Frontend · Specification
There is no hosted demo. There is one command.
git clone https://github.com/lain-the-coder/ea-qms
cd ea-qms/deploy/docker
docker compose up -dThen open http://localhost:8080.
| Role | Password | |
|---|---|---|
| Admin | [email protected] |
DevPassw0rd! |
| CC Owner | [email protected] |
DevPassw0rd! |
| Approver | [email protected] |
DevPassw0rd! |
| Approver | [email protected] |
DevPassw0rd! |
| Viewer | [email protected] |
DevPassw0rd! |
The second approver is assigned to nothing, and exists so one rule can be seen rather than described: sign in as them and open a record assigned to the other approver.
qms-migrator Exited is correct, not an error — it creates the tables and stops.
A one-shot container then seeds seven change controls covering all six
states, one of them rejected at both gates and eventually closed, by signing in
and driving the real API rather than inserting rows — so those records'
signatures and audit entries are the ones the application actually wrote.
DEMO_SEED=0 docker compose up -d gives a clean system instead.
The API reference is at http://localhost:1304/docs. Never installed Docker? deploy/docker/README.md starts from zero.
┌──────────────── T5 reject ───────────────┐
│ │
▼ │
(T1) ──▶ Initiated ──── T2 ────▶ Pending Impl Approval ─────┘
│ │
│ T3 │ T4 approve
▼ │
Cancelled │ ┌─ T8 reject ─┐
▼ ▼ │
In Implementation │
│ │
│ T6 │
▼ │
Pending Final Approval ─────────┘
│
│ T7 approve
▼
Closed
| # | From | To | Action | Actor | E-signature |
|---|---|---|---|---|---|
| T1 | — | Initiated | Create | CC Owner | No |
| T2 | Initiated | Pending Implementation Approval | Submit for Approval | CC Owner | Yes |
| T3 | Initiated | Cancelled | Cancel | CC Owner | Yes |
| T4 | Pending Implementation Approval | In Implementation | Approve | Approver | Yes |
| T5 | Pending Implementation Approval | Initiated | Reject | Approver | Yes |
| T6 | In Implementation | Pending Final Approval | Submit for Final Approval | CC Owner | Yes |
| T7 | Pending Final Approval | Closed | Approve | Approver | Yes |
| T8 | Pending Final Approval | In Implementation | Reject | Approver | Yes |
Permissions are per role, per state — 49 of 50 fields. What you may edit
depends on your role and the record's state, and at the two gates on a third
thing: whether you are the assigned approver, not merely an approver. A CC
Owner edits twenty-four fields while a record is Initiated and none afterwards.
The fiftieth field, supporting_documents, is defined in the specification and
descoped from release 1 — the more interesting half of the number.
Validation is collected, not fail-fast: a submission that is short of sixteen mandatory fields says so once, rather than sixteen times.
Every transition but creation is signed. The signer re-enters their own email and password, the system records what they attested to, and failed attempts are recorded too. Signatures are never overwritten: a record through a rejection loop carries both signatures at the gate it used twice.
The Admin manages people, not records. They create users, change roles and deactivate accounts — but cannot change a user's role, or deactivate them, while that user is attached to a record still in progress. Nothing can reassign a record, so either change would strand it at a gate nobody can act on.
| What it holds | Why it is separate | |
|---|---|---|
| Change-Control-HTML-Design | The BRD, the security matrix, the database design, the state, sequence and ER diagrams, and an HTML prototype of every screen | It was finished before the code began and it is a deliverable in its own right |
| ea-qms-backend | Go REST API. 23 endpoints, PostgreSQL, sqlc, goose, argon2id, JWT with opaque refresh tokens | Its own image, its own lifecycle, and consumable without a UI |
| ea-qms-frontend | Svelte 5 SPA, built in 18 verified steps. Caddy serves it and proxies /api/* onto one origin |
Same |
Three dates say more about the method than a description would:
- 20 April 2026 — the BRD is signed off.
- 18 July 2026 —
chore: scaffold Go backend + users table migration, the first line of Go. Eighty-nine days later. - 16 August 2026 —
docs(brd): V1.2 — align the BRD with what was built, nine amendments recording where the implementation departed from the specification, each with its reason.
Specification, design, build, verification, with traceability between them, and
the document corrected when the build proved it wrong rather than the deviation
left undocumented. The build ran as numbered, independently verifiable steps. A step was not
finished when it was written but when a stated check passed — something to click,
an endpoint to call, a SELECT to run — and nothing moved on before that. Every
decision was recorded with its reasoning and the alternative that was
rejected, including the ones that later reversed earlier decisions, which are
left in place rather than tidied away. Deferred items are kept in a register
separate from defects, because conflating the two is how real problems get lost
among accepted trade-offs.
The code was written with Claude Code, under a review gate: every line read and approved before it was committed, and no step begun before the previous one was verified.
The logs are the argument, not this paragraph: backend build log · frontend build log · and the coding guide's closing table, the four failures that produced most of these rules — an audit scope assumed as 24 fields when the spec said 9; a password trimmed on create but not on login; a sqlc query that compiled against a column that did not exist; a field left out of a keyed struct literal, writing NULL in silence.
A failed e-signature is not retried, because the retry would falsify an audit
trail. Every rejected signature returns 401 and writes a SignatureFailed
audit row. 401 is also what an expired token returns, and the client refreshes
and retries that one — so the client matches on the response body, not the
status. A blind retry would write two failure rows into a regulated record for
one thing the user did once. The care runs the other way too: a blank email
or password is a 400 caught before the record is even read, and writes nothing —
the trail records attempts to sign, not failures to fill in a form, which is what
makes a count of failed signatures mean anything.
(frontend decision 13)
Authentication is a compile-time property.
type authedHandler func(http.ResponseWriter, *http.Request, database.User) — a
three-parameter function is not an http.HandlerFunc, so it cannot be registered
on the mux at all without the middleware that supplies the user. Forgetting the
auth check stops being a thing a reviewer has to catch.
(coding guide §10)
A failed signature's audit row is written outside the transaction — with
cfg.db, not qtx. The single deliberate violation of the codebase's own rule
that everything inside a transaction uses the transaction, because the rollback
that reverts the attempted transition would otherwise erase the evidence that
the attempt happened.
(backend decision #31)
The security matrix says "Approver"; the API compares assigned_approver_id.
The specification described a role and the system enforces an identity. Following
the matrix literally would have offered two of three approvers a button
guaranteed to 403 — the spec was right about intent and imprecise about
mechanism, and the gap is recorded rather than silently closed.
Nine protocols, executed against this stack and passed — not against a development database, because what they test is the integrated system: the SPA, the proxy, the API and PostgreSQL together.
They are split by who executes them, which is the distinction a validated environment actually makes. Operational qualification is technical and run by whoever installed the system, with a terminal and a database session. User acceptance testing is run by a business user, in a browser, with nothing else — because a UAT script that asks its tester to open a terminal is an engineer's test wearing a UAT header, and the person whose acceptance is being sought cannot execute it.
The split is also why the best evidence in the project is recorded at all. The application has no audit-log screen, so one failed signature writing exactly one audit row and not two cannot be seen from the browser by anyone. It is OQ-04 §4, and UAT-04 points at it.
Both use the format computerised system validation uses in a regulated environment: an ID, an objective, prerequisites, numbered steps with an expected result, an actual result and a pass/fail, signed and dated. A protocol is evidence of execution; a step with no recorded actual result is not evidence of anything.
| ID | Title | Result | Executed |
|---|---|---|---|
| OQ-01 | Installation and startup | Pass | 2026-09-21 |
| OQ-02 | Audit trail and record integrity | Pass | 2026-09-21 |
| OQ-03 | Electronic signature records | Pass | 2026-09-21 |
| OQ-04 | Failed signature recording | Pass | 2026-09-21 |
| UAT-01 | Change control lifecycle, approved at both gates | Pass | 2026-09-21 |
| UAT-02 | Rejection at both gates, and recovery | Pass | 2026-09-21 |
| UAT-03 | Field-level permissions by role and state | Pass | 2026-09-21 |
| UAT-04 | Electronic signature controls | Pass | 2026-09-21 |
| UAT-05 | Administration and cancellation | Pass | 2026-09-21 |
All nine were executed by the author. In a section that argues about who executes what, that has to be said plainly: the UAT scripts are written to be run by a business user with nothing but a browser, and they have not yet been run by an independent one.
There is no HTTP-level test suite. There are nine unit tests over pure functions, a Postman collection carrying 174 saved responses, and a seeder that drives all eight transitions plus an evidence upload and download through the real API on every clean start. None of those is a test harness, and that gap is the most obvious next piece of work.
Date rules compute in UTC, so between midnight and 04:00 in Abu Dhabi the two business-day rules are a day lenient. Found by running into it, flagged, and deferred with the fix named rather than quietly patched.
There is no CSP, and it cannot simply be added as a proxy header: the policy and the SPA's build output have to agree, which makes it a change to the frontend build rather than a line in a Caddyfile.
Password reset, email notifications, saved searches and approver reassignment are out of scope by decision, recorded before the build began rather than discovered as absences afterwards.
All of it is written down — 103 flagged items across the two registers, each with why it was deferred and what would close it.
A solo project, built against a specification that came first. Its shape — the security matrix per role per state, the e-signature rules, the insistence that a failed attempt is still a record — comes from administering and validating systems that work this way, in an industry where they have to. That background explains why the project looks like this. It does not stand in for the code, which is what is being judged.
MIT licensed. See LICENSE.








