- Rust 52.3%
- PLpgSQL 23%
- TypeScript 18.6%
- JavaScript 2.7%
- CSS 2.5%
- Other 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`
|
||
| .forgejo/workflows | ||
| ci | ||
| client | ||
| crates | ||
| docs | ||
| fixtures | ||
| migrations | ||
| mobile | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| compose.deploy.yaml | ||
| compose.yaml | ||
| Dockerfile | ||
| Dockerfile.scheduler | ||
| README.md | ||
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
discrepancyqueue 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.