No description
  • Rust 52.3%
  • PLpgSQL 23%
  • TypeScript 18.6%
  • JavaScript 2.7%
  • CSS 2.5%
  • Other 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kyle Phillips 1d6184ce74
All checks were successful
CI / client (push) Successful in 11m45s
CI / check (push) Successful in 13m55s
CI / images (push) Successful in 3m57s
The wire grew a field and the client's copy of it did not
`d4ca713` added `arrival` to both import reports and the contract gate caught it,
which is the gate doing exactly what D113 leaves it there to do: `domain/types.ts`
is a hand-kept copy of the wire until types are generated, and this is what keeps
it honest.

Three things had to move and the third is the one worth noting. `FileArrival` and
the two `arrival` fields go into `types.ts`. The type also goes into `PAIRS`,
because an unpaired wire type is invisible to the gate — that is the allow-list
failure fixed on 2026-08-20, and the evidence it still holds is the count moving
from 75 types to 76 rather than the gate merely staying green.

The fixtures then had to say something true. `DRY` and the item master carry
`arrival: null`, because a dry run keeps no arrival: the loader rolls its writes
back and a receipt outliving that rollback would assert a file arrived which was
never kept. `APPLIED` carries a real one, since applying always files the bytes.

Gates: verify, build, build:review, render (85 screens, 227 renders) and frame,
each run alone. Clippy also re-run under 1.98.1, which stable moved to on
2026-09-01 and which CI's `rust:1-bookworm` now pulls: clean there too.
2026-09-04 22:09:44 +10:00
.forgejo/workflows Liveness is a different question from readiness, and Docker asks one of them 2026-08-24 13:15:54 +10:00
ci The Postgres signing key rides with the repo 2026-08-18 22:06:58 +10:00
client The wire grew a field and the client's copy of it did not 2026-09-04 22:09:44 +10:00
crates The export stopped existing the moment it was parsed 2026-09-04 18:11:14 +10:00
docs The API we have been waiting a fortnight for was the wrong thing to ask for 2026-09-04 18:10:53 +10:00
fixtures A pick lands where the goods land, and picking is leaving storage 2026-09-01 01:35:53 +10:00
migrations A pick lands where the goods land, and picking is leaving storage 2026-09-01 01:35:53 +10:00
mobile The bundle is named after the product, not the package 2026-09-01 06:59:56 +10:00
scripts Backups, and the check that makes them backups 2026-08-20 14:37:51 +10:00
.dockerignore Move to Forgejo, and let CI build what Coolify deploys 2026-08-17 11:56:20 +10:00
.gitignore The app is a shell around the client, not a second client 2026-08-31 14:19:12 +10:00
Cargo.lock The first administrator, made once, gated by a token 2026-08-17 21:47:58 +10:00
Cargo.toml The app is a shell around the client, not a second client 2026-08-31 14:19:12 +10:00
compose.deploy.yaml The healthcheck moves to the name it should have had 2026-08-24 14:15:10 +10:00
compose.yaml Migration 75: a key the person holds 2026-08-13 20:21:30 +10:00
Dockerfile Liveness is a different question from readiness, and Docker asks one of them 2026-08-24 13:15:54 +10:00
Dockerfile.scheduler Compose: API server on :8080 with multi-target Dockerfile 2026-08-12 08:15:12 +10:00
README.md An item that had to be received by lot could not be received at all 2026-09-01 17:58:49 +10:00

Nylonite

A warehouse management system for a distributor of food safety products running several sites across Australia, built to replace the warehouse side of Oracle NetSuite. Rust and PostgreSQL on the server, React on the client, Tauri on the handhelds. One deployment can serve more than one company, and that is true from the first database migration rather than added later.

The premise is that a scanner in someone's hand observes physical reality and the database only models it. When the two disagree the scanner is usually right, so scans are records of what happened rather than requests for permission, the movement ledger is only ever appended to, and disagreement becomes a finding with evidence attached rather than an adjustment nobody sees.

Ninety-one migrations build the database and reverse it cleanly. A thin Actix server reads and writes it, a scheduler drains the projections, and an invariant suite checks both. A React client serves the benches, the desk and the handheld from the same origin as the API, and the whole thing runs at app.nylonite.com, deployed from main by Coolify on a home server — see docs/deployment.md.

The server can already receive, move, adjust, count and pick: those write paths exist and go through the same ledger as everything else. What most of them do not have is a screen, and put-away has neither. docs/handover.md says where the edges are.

Running the schema

# Postgres :55432, API :18080, and nylonite-scheduler draining dirty tenants.
docker compose up -d --build
DATABASE_URL=postgres://postgres:nylonite@localhost:55432/nylonite \
  scripts/migrate.sh
curl -s localhost:18080/api/health

migrate.sh applies only what the database does not have and records each one in schema_migration (D137). --status says what is pending; --baseline records an existing database as current without running anything.

Apply migrations before relying on the API or scheduler (both only need a healthy Postgres to start). The deployed stack does this for you — see docs/deployment.md, where a migrate service runs to completion before the server starts, so a failed migration is a failed deploy rather than a server answering 500 behind a green health check. The server connects as postgres then SET ROLE nylonite_app (nylonite_app is NOLOGIN). Scheduler interval defaults to five seconds (SCHEDULER_INTERVAL_SECS).

Sign on first: x-tenant-id is gone. The tenant comes from the session, and so does recorded_by_id — D11's "never client-supplied, and never editable", which was a number the request typed until migration 70.

curl -c cookies.txt -H "content-type: application/json" \
  -d '{"email":"kyle@example.test","password":"dock-station-1","site_id":"<site>"}' \
  localhost:18080/api/sessions
curl -b cookies.txt localhost:18080/api/sessions/current

A handheld sends the same token as Authorization: Bearer <token> instead of holding a cookie; one record, two transports, one revoke.

Weighing

/weigh puts one thing at a time on a scale. This is the act that makes an interval mean something: until a value has been measured once, its observed_at is the date of the import that carried it, so no schedule can be built on it.

The worklist is derived, never stored — a function of observation_current's observed_at and method, so a "needs confirming" flag would be a third column able to disagree with the two behind it. It is two lists: a value nobody has measured needs a first weighing whatever its apparent age, and a value somebody did measure needs re-weighing when it gets old. Ranked by how often the thing appears on open orders, which is ABC analysis applied to weights instead of quantities.

POST /weighings records it as instrument off a scale, and where the reading disagrees materially with what it replaced, both stay on file and a finding carries the pair. A tenth, with a five-gram floor: ten per cent of a 200 g glove is arguable and ten per cent of a 300 kg pallet is not.

Passkeys

A password is a secret the server also knows. A passkey is a private key that never leaves the authenticator: the server holds a public key, checks a signature, and has nothing worth stealing. On a shared handheld, that is the difference between D11's non-repudiable floor holding and not.

/keys enrols and revokes, and /sign-in offers a key beside the password. It needs no email: a discoverable credential carries the user handle, so the authenticator offers what it holds for this origin and the server learns who it is talking to from the assertion. That is the version that suits a shared handheld, and typing an address narrows it to that person's keys where somebody would rather.

Signing in with a key did not work between 20 and 31 August. The server's ceremony was written, tested and reachable the whole time and no client called it — the sign-in page lost that half when it ported to React, and nothing failed, because no gate asserted that an endpoint had a caller. npm run frame now drives the ceremony against a Chrome virtual authenticator, which is what turns that into something a build can see.

Somebody who works for two companies is asked for their key twice: the ceremony is claimed exactly once, so the 409 that lists the companies has spent it and choosing one starts a second. The screen says so before it happens.

Migration 75 stores the public key, and the ceremony state lives in webauthn_challenge — on the server, because handing it to the browser and taking it back means trusting the party being authenticated to remember what they were asked.

A credential is bound to its origin. NYLONITE_RP_ID and NYLONITE_RP_ORIGIN configure it, defaulting to localhost, which is a secure context by specification so development needs no certificate. Change the hostname and every key registered against the old one is dead — which is why the demo needs a stable name rather than a quick tunnel's random one. A mismatched pair stops the server at boot rather than becoming a sign-in button that silently does nothing.

Revoking disables and never deletes: a key that signed movements is part of how those movements are attributed.

Tenancy is a session setting, so a query sees one tenant's rows and no others:

SET ROLE nylonite_app;
SET nylonite.tenant_id = '...';
SELECT code FROM site;   -- that tenant's sites, and nothing else

Where to start

  • docs/handover.md, start here if you are picking this up: what is built, what will bite you, and where the next piece begins
  • docs/deployment.md, where it runs and how it gets there
  • docs/architecture.md, the design in plain terms
  • docs/proposal.md, the case for building it, written for a mixed executive and technical audience
  • docs/domain-model.md, the domain decision record, D1 to D169 across the shared register, with the reasoning and what each decision rejected
  • docs/interface-design.md, the frontend and the material system: the interface decisions on the shared register, D109 onward, with the corrections that got there
  • docs/migration-1.md, what has to be in the first migration and why each item cannot wait

The three registers

Each is the single authority for its kind, and anything raised anywhere is migrated to its register in the same commit that adopts the decision raising it.

Register Holds
docs/domain-model.md Every decision, D1 onward
docs/invariants.md Every rule the design must satisfy, structural and job-asserted
docs/open-questions.md Every open question, with what each one blocks

Research documents may state questions and invariants. They do not own the numbering, and they carry a banner saying so. Where a research document and a register disagree, the register wins.

That rule exists because prose which was true when written and quietly stopped being is the recurring failure on this project. Before the registers existed, questions lived in five documents with three numbering collisions and a first-migration blocker sat unmigrated for weeks.

Current state

One hundred and sixty-nine recorded decisions, a hundred and twenty-nine rules the design must always satisfy, fifty-seven open questions. Seventy-one of the rules state that something does not happen, and a rule of that shape passes when there is nothing to check, so each one needs a companion rule saying the thing it inspects is actually there.

The schema carries two ledgers, and everything else is a reading of one of them. stock_movement records what moved and stock is a fold of it. observation records what was measured and observable is the registry of things a measurement can be about. A projection may be rebuilt from either at any time and has to come out identical, which is asserted rather than assumed.

  • Tenancy and identity. Row-level security on every tenant-scoped table, a session that names the site somebody signed on at, passwords a person can change, passkeys, and import tokens that are deliberately not people.
  • Place. Sites, zones and locations; containment held as intervals rather than a current-position column, so "where was this at four o'clock" is a question the schema can answer; and the order a picker walks stored on the location.
  • The catalogue. Items, the style a measurement is really about, the parts a two-piece product is measured as, classes with closure tables, barcodes, and packing presets that state a carton's size.
  • Outbound. Orders, fulfilments and their lines, packages with a placement log, consignments. Progress is four coverage quantities computed from the ledger, never a stored status column.
  • Inbound. Expected supply with netting and pre-receipt allocation, receipts, and a counterparty's claim stored exactly as it arrived and never revised.
  • Findings. One discrepancy queue where a short pick, a receipt variance, a count and the invariant suite's own disagreements all land, each with an owner and its evidence. Nothing may delete one.
  • Policy. A scope lattice with most-specific-wins resolution, where the entire matching language is "is this node an ancestor-or-self of that node". No operators, no expressions, nothing that makes it a rules engine.

The privilege model is part of the schema rather than a convention. The application role may read stock and may not write it, because the projection owner does that; GRANT is the enforcement and not code review.

Why each migration is shaped the way it is belongs to the decision it came from, in docs/domain-model.md, and to the commit that added it.

The client

React, mounted at the root and served from the same origin as the API because the session is a cookie — a separately-hosted bundle would trade a working sign-on for a SameSite problem. The server image carries the bundle; index.html is no-cache and the content-hashed assets under /assets are immutable for a year. The API lives under /api, so no page name can ever be claimed by an endpoint.

open https://app.nylonite.com/           # the work waiting at your site

Signing in lands on /, which is what is waiting at the site you signed on at, and every screen is reachable from the rail beside it. A person whose session names no warehouse is sent to /where once and never sees it again.

The way in is the work, not a search box. The recorded process opens by typing a confirmation number into a search bar, because the system it describes has no other way to reach a job. Here the front door is what is waiting, the pack queue is grouped by how far each job has got, and /orders opens on the latest rather than an empty field. Searching is what you do when somebody rings up quoting a number.

Route What it is
/ The work waiting at this site, grouped as the rail groups it
/orders · /pack · /pack/:fulfilment Find an order or read the latest, then the bench for one commitment
/despatch Cartons onto a consignment, and the manifest a carrier would be handed
/weigh · /findings · /findings/:finding Confirm a weight, the queue of disagreements that raises, and one finding — a link somebody can be sent
/capture · /picking The handheld surface: a measurement session, and the pick walk
/workspace · /import · /tokens · /keys · /account Administration, and your own credentials
/print/packing-list/{id} The A4 document, still server-rendered, per D113

Pack is stages 3 to 8 of the recorded process on one screen: what is left, where to pick it from, a carton per preset, and the two boxes — weight and height — that are the only genuinely variable input in the whole process. The footprint comes from the preset, because a small box is the same size whoever packs it.

Despatch is stages 6 to 9, which had no interface in any form. A consignment is a row with an identifier, so nothing is matched by eye; the carton count is a fold over what is physically in the boxes, which is the number the walkthrough says exists nowhere; and the manifest states what a carrier would be given, refusing to state one size when the cartons of a kind disagree.

Fixtures are not in the deployment. Every screen state is reachable from a fixture under /fixtures/* — which is how a state that a working deployment has grown out of, like the setup screen, stays reviewable — but those routes exist only in the review build. npm run build produces what ships and contains none of them; npm run build:review produces what the render gate measures.

The design system is client/design: two materials under one lamp, six layers, and a set of laws in docs/interface-design.md that CI enforces. Six gates run on every push — typecheck, the token contract, the client/server type contract, the design laws, a headless render that measures the page rather than reading the source, and a headless run of the shipped bundle that checks the frame is mounted once and stays mounted across a navigation.

cd client && npm run verify   # the first four
npm run build && npm run frame
npm run build:review && npm run render

Measuring a thing

observation_event records who looked, when, on which device and by what method. measurement hangs the figures off it, and since migration 78 observation_image hangs the photographs off the same event — so the pictures and the figures provably came from one look at one box.

Bytes are content-addressed behind crate::images rather than stored in the database: a filesystem volume needs nothing external (D39), the name of a file is the SHA-256 of its own contents, and an object store is the same seam later. Uploads read the type from the bytes rather than believing the header.

curl -b cookies.txt --data-binary @front.jpg -H 'content-type: image/jpeg' \
  localhost:18080/api/observations/<event>/images/front

Seven faces: front, back, left, right, top, bottom, and label — the last being the carrier label and the barcode, which is the one that is not a side and the most useful of them.

A capture session is one act (D133): every figure travels in one POST /observations, and the photographs hang off the observation_event_id it returns, so "these pictures and these figures are the same look at the same box" is a join rather than an inference. GET /capture is the worklist behind it — what wants weighing, measuring or photographing at this site, as one list in bin order, because the thing an operator is holding is a lap of the warehouse and not a triage queue. Each row carries the bin holding the most of it and how much is there, and says what the trip is for and why the row is present. The screen is at /capture.

A measurement names the state the thing was in (D138). An apron folded twice is 250×180×30 and the same apron in a heap is something else, so observation_event.presentation_id records the arrangement, and the writer refuses a length at each without one — the number and the word are what make it reproducible. It sits on the event beside method rather than being a metric of its own, because observable_id is on the event: one act is one subject and therefore one arrangement, so the presentation cannot come adrift of the dimensions it qualifies. J72 asserts the same rule about the data, because the prepack loader reaches the table without going through the writer.

And some things have no dimensions. A measurement may carry absent_reason: "not_applicable" instead of a value — a two-part set has no bounding box, and until D138 the only way to say so was to leave the row empty, which is what nobody has looked looks like. The worklist stops asking. Two of the column's four words are refused: not_measured is what an absent row already says, and retracted belongs to retracts_observation_id.

A record can point at the look that supports it (D140). An observation_event already says who looked, when, at which subject and by what means, and observation_image already holds the bytes — so evidence needed no new table for the picture, only one for the sentence this look supports that record. evidence is that link, with arms for findings, receipt lines, counts, adjustments and despatch events. Optional everywhere and required nowhere: nothing gained a NOT NULL and a record with no evidence is the ordinary case.

curl -b cookies.txt -H 'content-type: application/json' \
  -d '{"discrepancy_id":"…","package_id":"…","note":"crushed on the left face",
       "client_event_id":"…","occurred_at":"…"}' \
  localhost:18080/api/evidence
# then the photographs, against the observation_event_id it answers with

POST /observations is deliberately not relaxed to take zero measurements: an accidentally empty request would claim the client_event_id, and the retry carrying the real figures would hit the replay branch and be handed the empty act's nothing back. So a look that produces a picture instead of a figure is its own act, which is the shape POST /weighings already has. Its method is photographed, because calling a camera an instrument would eventually let revalidation read a photograph as evidence that something had been weighed.

A picture inherits for recognition and never for evidence (D141). The capture worklist asks has anyone looked at this subject and inheritance would answer that falsely; a picker asks what am I looking for, and the style's carton is a good answer across thirteen sizes of one boot. Same bytes, two reads, and D108's own/style label on every answer is what keeps it honest — an unlabelled borrowed picture is the pixel version of a number nobody took against this code. Front face only, newest wins.

The pick walk (GET /sites/{id}/picking, /picking) is where it shows: what is outstanding, which bin, in walking order by location.pick_sequence — the first read of a column only J71 had an opinion about — and what the thing looks like when you get there. A read only. Which carton a pick goes into is a workflow question the model declines to answer for somebody else's floor.

The parts carry the sizes (D139). item_part is the sixth observable arm — a pan and its 1200mm handle under one sellable code, measured separately because the set they make has no box of its own. A part is measured and not sold, which is what makes it not an item: the day one is stocked, picked or sold alone it has become an item, and that is a migration. A part subject posts item_part_id and no packaging level.

Importing a catalogue

The item master and the prepack list arrive as CSV exports. import_prepack reads both, decides what each prepack row is about, and writes nothing until you have read the report.

cargo run -p nylonite-server --example import_prepack -- \
    --items Items444.csv --prepack WMSPrepackList237.csv          # dry run
cargo run -p nylonite-server --example import_prepack -- ... --apply

A prepack row is read against the catalogue, not against its own shape. A name the item master knows exactly is an item; a name two or more codes are variants of is a style, and its carton stands for all of them (D108); a name that is neither and does not look like a code is a container. A name that does look like a code and is absent from the catalogue is written nowhere and reported — the real export was missing 91 of them, and calling those containers would have invented 91 box types.

Acts are v5 uuids over (file, row), so re-running the same file writes nothing. Where two rows describe one subject and disagree, both are recorded and an identity_mismatch finding is raised: the file has no timestamps, so preferring either would be arbitrary.

import_orders loads sales order lines: customers, orders, lines, and one planned fulfilment per order with nothing picked — which is correct by construction, because nothing has moved in this system yet. It reads the export positionally, since NetSuite emits two columns both called Name (the item and the customer) and any header-keyed reader silently discards one of them. A fulfilment line carries ordered − already fulfilled, because loading the ordered figure would put units on the bench that shipped last week.

import_bins does the same for a bin list: warehouses become sites, bins become locations, and a bin code is broken into the aisle, bay and level columns migration 1 created and nothing had ever filled.

cargo run -p nylonite-server --example import_bins -- --bins Bins123.csv

WMS Picking Order becomes location.pick_sequence, the order a picker reaches each bin. 0 is read as absent: the export uses it 150 times to mean unsequenced — including 3PL and ASSEMBLY-BIN — and loaded literally every one sorts ahead of the first real bin, so location_pick_sequence_ck makes 0 unrepresentable. Not unique, deliberately: two bins at one position is a finding (J71), and the real list has two such pairs.

It refuses three things rather than guessing: a bin stating no type (kind is NOT NULL and the vocabulary has no value meaning "nobody said" — --assume-kind if you know), a warehouse that looks like somebody else's (--include-external), and a site whose timezone is unknown, because defaulting one is wrong in Brisbane twice a year.

The server

An Actix server on the same database, deliberately thin. Two rules live in it rather than in the schema, both for the same reason: they compare rows, and Postgres has no cross-row CHECK. Corrections check a proposed reversal against the movement it names; receiving decides which claims a partial arrival hands over. Both are pure functions with the job-asserted invariants as their backstop, for rows that arrive by a restore or a path nobody has written. What it demonstrates is the thing that has to be right before any endpoint is worth writing: tenancy is enforced by the database, per transaction, and the server refuses to start if it is connected in a way that turns that off.

# Prefer compose (server + scheduler + postgres). Host API port is 18080.
# DATABASE_URL=postgres://postgres:nylonite@localhost:55432/nylonite \
#   BIND=127.0.0.1:8080 cargo run -p nylonite-server
curl -b cookies.txt localhost:18080/api/stock
curl -b cookies.txt localhost:18080/api/orders                      # the latest at your site
curl -b cookies.txt "localhost:18080/api/orders?reference=S260041"  # or the one somebody quoted
curl -b cookies.txt localhost:18080/api/package-types
# The two bench reads, each the whole of a screen in one request.
curl -b cookies.txt localhost:18080/api/fulfilments/<id>/bench
curl -b cookies.txt localhost:18080/api/sites/<id>/despatch
curl -b cookies.txt "localhost:18080/api/discrepancies"
curl -b cookies.txt "localhost:18080/api/discrepancies?state=open,investigating&kind=count_variance"
curl -b cookies.txt "localhost:18080/api/discrepancies?item=GLOVE-M&location=A-01-1"
curl -b cookies.txt "localhost:18080/api/discrepancies?older_than_hours=24&state=open,investigating"
curl -b cookies.txt localhost:18080/api/discrepancies/<id>
curl -b cookies.txt -H "content-type: application/json" \
  -d '{"note":"checking bin"}' localhost:18080/api/discrepancies/<id>/investigate
curl -b cookies.txt -H "content-type: application/json" \
  -d '{"recorded_by_id":"<person>","reason":"within tolerance"}' \
  localhost:18080/api/discrepancies/<id>/accept
curl -b cookies.txt localhost:18080/api/sites/<site-id>/open-lines
curl -b cookies.txt localhost:18080/api/fulfilments/<id>/lines
curl -b cookies.txt localhost:18080/api/fulfilment-lines/<id>
curl -b cookies.txt localhost:18080/api/packages/<id>
curl -b cookies.txt localhost:18080/api/packages/<id>/contents
curl -b cookies.txt -H "content-type: application/json" \
  -d @package.json localhost:18080/api/packages
curl -b cookies.txt -H "content-type: application/json" \
  -d @place.json localhost:18080/api/packages/<id>/place
curl -b cookies.txt -H "content-type: application/json" \
  -d @contain.json localhost:18080/api/packages/<id>/contain
curl -b cookies.txt -H "content-type: application/json" \
  -d @pick.json localhost:18080/api/picks
curl -b cookies.txt -H "content-type: application/json" \
  -d @seal.json localhost:18080/api/packages/<id>/seal
curl -b cookies.txt -H "content-type: application/json" \
  -d @open.json localhost:18080/api/packages/<id>/open
curl -b cookies.txt -H "content-type: application/json" \
  -d @despatch.json localhost:18080/api/packages/<id>/despatch
curl -b cookies.txt -H "content-type: application/json" \
  -d @correction.json localhost:18080/api/corrections
curl -b cookies.txt -H "content-type: application/json" \
  -d @count.json localhost:18080/api/counts
curl -b cookies.txt -H "content-type: application/json" \
  -d @adjustment.json localhost:18080/api/adjustments
curl -b cookies.txt -H "content-type: application/json" \
  -d @receipt.json localhost:18080/api/receipts
curl -b cookies.txt -H "content-type: application/json" \
  -d @move.json localhost:18080/api/moves
curl -b cookies.txt -H "content-type: application/json" \
  -d @allocation.json localhost:18080/api/allocations
curl -b cookies.txt -H "content-type: application/json" \
  -d '{"force":false}' localhost:18080/api/allocations/<id>/release
curl -b cookies.txt -X POST localhost:18080/api/projections/refresh

Every query carries no WHERE tenant_id. It does not need one and adding one would be worse than redundant: row level security applies the predicate, so a handler that forgets it returns nothing rather than everything. Which tenant that is comes from the session — tenant_from used to read a header and was deleted rather than left unused, because a helper that reads a tenant from a request is one somebody reaches for again.

The fulfilment and package routes read the outbound progress folds (D99–D103). Write paths insert facts only and never UPDATE projected quantities. Under D107 they also return a live ledger view of the same numbers (O(entity), not a full rebuild) and mark the tenant dirty so the scheduler prioritises it:

Method Path Facts written Response extras
POST /packages package skeleton + created event marks dirty
POST /packages/{id}/place placed event (location) status ledger; marks dirty
POST /packages/{id}/contain contained event (parent package) nest carton on pallet; marks dirty
POST /picks stock_movement (pick) ledger + projection; marks dirty
POST /packages/{id}/seal sealed event (+ optional sealed_at) package_status_ledger; marks dirty
POST /packages/{id}/open opened event (clears sealed_at) rework; despatched/voided refused
POST /packages/{id}/void voided event discard a carton the packer abandoned; refused while it still holds stock, folded from the ledger rather than read from stock
POST /packages/{id}/despatch despatched event + stock_movement (no to) ledger + projection + status; marks dirty
POST /corrections reverse stock_movement (adjustment) mirrors target; marks dirty
POST /counts stock_count assertion variance → count_variance finding; no ledger write; act-idempotent on client_event_id
POST /adjustments world-event adjustment movement optional discrepancy_id / stock_count_id → sets resolving_movement_id, resolves finding; act-idempotent
POST /receipts line (+ open/join header) + dispose + optional movement Q172 header join; Q173 entered_* + packing config → base units; claim → policy → dispose; over-receipt / lot_missing; policy_ambiguous row
POST /moves stock_movement (move / putaway) marks dirty
POST /evidence observation_event + evidence a look offered in support of a record (D140). One route, five record types, none of them obliged to use it; the event and the link are one act under one client_event_id, and the photographs follow against the event id it answers with
GET /sites/{id}/picking — the pick walk (D141). What to pick, which bin, in walking order, with a picture of each thing and whose picture it is
POST /observations observation_event + observation per metric the second spine table's write path. One subject, one moment, many metrics; entered_value is a string so a float never touches what the operator typed; converts to base units in the writer (Principle 5); sets the four package dimension columns J12 compares against; act-idempotent. Takes presentation and refuses a length at each without one (D138), accepts absent_reason in place of a value, and takes item_part_id as a subject (D139)
POST /allocations stock_allocation (cell → line) live ledger coverage; marks dirty; Q174 retry-safe on a client-minted id — replay returns the prior claim, a reused id with a different body is refused
POST /allocations/{id}/release set state = released only allocated; firm needs force
GET /discrepancies — findings queue; default state=open; filters kind, item/item_id, location/location_id, package/package_id, stock_count_id, since/until, older_than_hours
GET /discrepancies/{id} — one finding (full row)
POST /discrepancies/{id}/investigate set state = investigating open → investigating; full row + warnings; idempotent
POST /discrepancies/{id}/accept set state = accepted open|investigating → accepted; full row + warnings; no ledger write; requires recorded_by_id + reason
POST /consignments consignment + consignment_package the carton count as a fold, not a number retyped into a freight system; sealed cartons only; client-minted id for retry safety; writes no status/eta/price — those are the carrier's answers
GET /consignments/{id} — the same parcel summary, with a warning where cartons of one preset differ in size or carry no weight
GET /orders?reference=X — stages 1 and 2 in one request: match on confirmation_number or external_ref, with the contact to confirm against and each fulfilment's site and progress. fully_picked is computed from the lines, because S44 forbids a stored status column
GET /packages/{id}/contents — the packing list: contents folded from the ledger through stock_movement_effective (D103) and named back to the order line each unit serves (D53), beside what the stock projection holds; disagreement is a warning, not a hidden preference
GET /items/{id}/measurements observation_current what a thing of this kind measures, per packaging level — as against /packages/{id}, which is the object in front of you
GET /package-types — packaging presets, shipped and tenant-owned in one list; tenant_owned says which this business may edit
GET /fulfilment-lines/{id} — projection columns + live ledger
GET /packages/{id} — status (cache) + status_ledger
POST /sessions session sign on (Q171/D11): Argon2id verify, opaque token, __Host- cookie and a bearer for handhelds; one generic refusal for wrong password, unknown address and locked account alike
GET /sessions/current — who the caller is, or 401
DELETE /sessions/current revoke signs off everywhere at once
POST /projections/refresh full-tenant rebuild via wrapper rate-limited 5 s; never bare projection_run_all

Progress and placement catch up when nylonite-scheduler drains dirty tenants via projection_run_dirty (or on refresh). Compose starts postgres, the server (:8080), and the scheduler from one multi-target Dockerfile. Over-available picks/despatches/moves are recorded with a warning (D5); structurally invalid writes and malformed corrections are rejected.

Floor writes that mint a client_event_id are act-idempotent (D5/D25): the same (tenant, client_event_id) on retry returns the prior fact ids and does not insert a second movement, count, or package event. Quantity mismatch on replay is rejected. Soft finding transitions (investigate / accept) use state-machine idempotency instead.

POST /allocations is retry-safe on the identifier rather than on an act envelope (Q174). An allocation is an Intention, so S19 asks it for no client_event; what makes the retry a replay is the client-minted id, which is the half of D5 that was always doing the work. The replay is settled before the over-cover check, because the coverage fold already counts the claim being retried — checking first refuses a legitimate retry as a doubly-committed line.

Measurements are the half of the spine that had no writer until now. Architecture names two tables that carry everything — stock_movement and observation — and for a long time every endpoint wrote the first and none wrote the second. POST /observations was the first that did, and it is what makes stage 4 of the recorded pack process (dimensions and weight on a pallet, "the only stage with genuinely variable, physically-measured input") something the API can perform. J12 reported VACUOUS from the day it was written because no fixture package had ever been measured; seed.sql now carries a weighed Australian standard pallet and it examines something.

The suite includes a happy-path database test (crates/server/tests/happy_path.rs) that walks create → place → allocate → pick → seal → despatch in one transaction: facts land, projections are not written by the floor path, live coverage moves with allocation and pick, and a mediated refresh after pick materialises package-held stock so despatch can leave.

The invariants are a suite rather than a document, in two classes with different contracts. A structural failure blocks a deploy. A job-asserted failure raises a finding and never stops the floor, so those checks return findings and the harness decides what to do with them.

How many are implemented is not written here, and that is deliberate. This paragraph used to carry the counts and they were wrong by the time anyone read them: it claimed a hundred and five invariants when there were a hundred and twenty-one, and forty-six pending when there were twenty-four. The suite prints the live numbers on every run, split into passed, vacuous and pending, and a number printed by the thing it describes cannot drift from it. The counts that are written down — in Current state above — are the ones a test checks against the registers.

Each pending entry names what it is waiting for, and what it names is an object rather than a sentence. S42 checks that the object is still missing, because for fourteen entries it was not: they went on reading as deliberate gaps for as long as five migrations after the thing they waited for was built. Eleven blockers name something outside the database — a code registry, an AST pass, an open question — and those are counted and reported rather than checked, so the size of the unverifiable set is a number rather than a silence.

There are two fixtures, with opposite jobs. seed.sql is small and hand-written, every value stated, so each implemented check has a non-empty population and two runs are byte-identical. history.sql is a generated year — 365 days, 289,000 stock movements, 500 items skewed so a few move constantly, forty people with turnover — loaded into its own tenant so the small fixture's numbers stay exactly what they were. It exists because some questions cannot be answered by reading: which foreign keys need indexes, what the receiving queries actually cost, whether a partial index selects anything. scripts/measure.sh builds the year twice from nothing to prove it is reproducible, then runs those queries and prints what they cost. Its length is a parameter — -v days=N — so the same file serves the measurement and the cheap load-check in verify-migrations.sh without a second copy of it drifting.

The whole suite holds against a year in about two seconds. Folding every projection over it takes about half a second, remeasured under D106 at roughly 526 / 551 / 626 ms against 289,080 movements, and about 34 ms against the small fixture. D95 had measured ~2 s; between then and D105 the year fold had grown to ~12.8 s, almost all of it one join of stock_movement_effective against every receipt line. D106 scoped that arithmetic to the roots each fold needs. The drift this README warns about, caught by measuring again rather than by reading the old number.

Every maintainer is still a full-tenant fold, so its cost is O(history) rather than O(the write that prompted it). That is why nothing on the write path may call one, why a projected number is always behind the ledger, and why projection_step.freshness_bound says how far behind each one may fall before J66 raises it as a finding. O(delta) waits until a step's measured cost is a material fraction of its bound — D106's settlement of question 164.

scripts/verify-migrations.sh
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f fixtures/seed.sql
DATABASE_URL=postgres://postgres:nylonite@localhost:55432/nylonite \
  cargo test -- --nocapture

All of it runs in CI, at .forgejo/workflows/ci.yml. Three jobs: the Rust suite against a postgres:18 service, the five client gates including the browser render, and — on main, and only once both are green — three images pushed to the Forgejo registry that Coolify deploys from. The workflow's header is the setup guide and the debugging surface: which variables and secrets to set, why the namespace includes the repository name, and what a job queued forever with no error actually means.

verify-migrations.sh runs the migration set five ways: up from empty, down from empty, up and seed, the generated year, and down again with both fixtures' rows in place. The last is the one that matters most and the one that was missing longest. Every commit here has claimed the schema reverses cleanly, and for a long time that was checked only against an empty database — which is not the state a down migration is ever run in. Testing it found one down that could not run at all.

The generated-year phase loads one day rather than the year, and the reason is what it was added for. history.sql could not load at all for three migrations: D92 added goods_receipt_line.quantity with a CHECK pairing it to entered_quantity, the generated year still wrote only the entered form, and nothing noticed because the only script that loads it is measure.sh, which no test runs. The statements are what needs checking rather than the volume, and a day exercises every INSERT in the file at 0.15s against two minutes for the year. measure.sh still takes the full year, because measuring a smaller population would answer question 142 about a database nobody runs.

The fixture is deterministic: fixed identifiers, fixed timestamps, no now() and no random(). Without it the job-asserted numbers describe whatever happened to be in the database rather than the schema, and three passing checks on an empty database reads the same as three failing ones.

One test performs a day's work rather than asserting a property. inbound_walk.rs takes a single delivery from the supplier's message to stock on a pallet — despatch advice, declared pallet, declared contents, our position on the claim, resolution, receipt, count, disposition, the pallet scanned into existence, the ledger entry — and runs every step as nylonite_app. It exists because D94 found a function nobody could call and a grant nobody had taken away, both live for four migrations while every structural check passed: the path had never been executed by the role that would execute it. Where the walk stops, a companion test names the missing object in S42's idiom, so filling the gap makes this file fail and demand to be extended rather than quietly going stale.

Two invariants carry a replay property, and each is a separate test rather than a check, because "replay in any arrival order is identical" cannot be verified without perturbing the order. J6 shuffles when package events were received and refolds five times. J46 does the same to amendments, but re-spaces their arrival times without reordering them, because the amendment fold reads recorded_at as a real term rather than as a pure tiebreak, and a random restamp would be testing that the fold ignores something it is supposed to read.

A vacuous result is reported separately from a pass, because a check that examined nothing proves nothing. That distinction is the whole reason the suite exists rather than a green tick.

The rest of docs/migration-1.md is column additions. Nothing in the decision record blocks them.

History

This repository was split out of warehouse-utilities, which keeps the label maker and the other small tools. The commit history here is the design history, carried across in full rather than squashed, because the commit messages are part of the record.

Most of the design documents predate the name Nylonite and do not use it.