13. Developing¶
Repository layout¶
db/
├── migrations/ schema, partitions, indexes, grants: the source of truth
├── seed/ the development dataset
├── checks/ assertions run by `make verify`
└── init/ the migration and seed runner
backend/
└── src/ NestJS: API, auth, provisioning, audit, MQTT consumer, live feed
edge/
├── wardn-domain/ decision logic: no I/O, no clock, no database
├── wardn-edge/ adapters: encrypted store, reader bus, relay, forwarder, telemetry, OTA
└── bootstrap/ wiring and fallback rights for a cabinet that boots with nothing
frontend/
└── src/app/ Angular: core (auth, API, live socket), shared, one folder per screen
infra/
├── keycloak/ the realm, imported on every start
├── minio/ buckets, a scoped service account, seeded documents and releases
└── tls/ the development CA and one identity per party
scripts/ the executable scenarios, all of them run in CI
docs/ this documentation
Where things live¶
| Looking for | Look in |
|---|---|
| The decision rules | edge/wardn-domain/src/decision.rs |
| Value normalisation | edge/wardn-domain/src/credential.rs and backend/src/common/credential.ts |
| The cabinet's local store | edge/wardn-edge/src/store.rs |
| Store & Forward | edge/wardn-edge/src/store.rs and mqtt.rs |
| OSDP framing | edge/wardn-edge/src/osdp.rs (pure) and osdp_bus.rs (I/O) |
| The rights queue | backend/src/sync/ |
| The fingerprint | backend/src/sync/fingerprint.ts and store.rs::rights_fingerprint |
| Authentication | backend/src/auth/ |
| Tenant confinement | tenantScope() in backend/src/auth/principal.ts, and every repository query |
| The three journals | db/migrations/0001_core_schema.sql, backend/src/audit/ |
| The schema, roles and grants | db/migrations/ |
Running the suites¶
make edge-test # cargo test + clippy -D warnings + cargo fmt --check
cd backend && pnpm install && npx tsc --noEmit && npx jest
cd frontend && pnpm install && pnpm build && pnpm test
pnpm build on the dashboard is the type check: Angular compiles templates, so a signal renamed in a component fails there rather than in a browser.
Two kinds of test¶
Unit tests, next to the code¶
Rust tests live in the module they exercise; TypeScript tests sit beside their subject as *.spec.ts. They cover the things that are cheap to get wrong and expensive to notice:
- every branch of the decision function, including the carpool case, the half-open validity interval, and the fact that candidate ordering does not change the outcome;
- the queue: that it is bounded, that it drops the oldest, and that drops are counted;
- the clock: that it catches a board that came back in the past, tolerates a small backwards slew, and keeps saying so until real time catches up;
- the rights delta guards: a stale revision is inert, an incomplete upsert is rejected, an empty full sync does not empty a populated cabinet;
- the fingerprint, pinned on both sides by the same golden vector;
- pagination cursors, RFC 9457 problem shapes, Sentry scrubbing, tenant scoping.
The OSDP decoder is additionally held to property tests: a decoder is the one part of a reader adapter where a mistake is silent. A bad CRC reads as a broken cable, a misread bit count as somebody else's badge.
Scenarios, against the real stack¶
Each script in scripts/ starts from a running stack, exercises one claim end to end, prints what it checked, and exits non-zero when something did not hold.
| Script | The claim |
|---|---|
demo.sh | Every decision branch behaves as documented |
offline.sh | An uplink outage loses no passage, and replays are flagged |
sync.sh | A right changed through the API changes what the door does |
api.sh | Authentication, tenant confinement, immutability, an attributed audit trail |
dashboard.sh | A passage reaches an open feed live, and a second tenant hears silence |
fleet.sh | Telemetry is real, a log level changes without a restart, debug streams and stores nothing |
commands.sh | A click moves a physical door; a mode change changes how the cabinet decides |
ota.sh | A release installs; a corrupt one is refused; a broken one is rolled back |
compliance.sh | A request can be traced, a person exported and erased, retention purges what expired |
two-instances.sh | One passage, one row, both dashboards |
mtls.sh | Both ends prove who they are; a stranger is refused; a cabinet cannot reach another's topics |
load.sh | Every passage decided inside the budget and accounted for |
backup.sh / backup-verify.sh / restore.sh | A backup is taken, restores, and says what it cannot restore |
tls-check.sh | What every certificate in the stack has left |
They are run with make <target>. See 2. Getting started.
Why scripts and not a test framework? Each of these asserts something that a green HTTP response would not establish: that a physical door moved, that a second tenant heard nothing, that a cabinet came back on the firmware it had. They are also the fastest way for a newcomer to watch a feature work.
Continuous integration¶
flowchart TD
I["images<br/>build backend, cabinet, dashboard once"]
subgraph NB["No image needed"]
E["edge: fmt, clippy, cargo test"]
B["backend: tsc, jest"]
D["dashboard: build, test"]
S["socle: migrations, grants, idempotent seed"]
K["keycloak: the realm issues the right roles"]
end
I --> ES["edge scenarios"]
I --> OU["outage and replay"]
I --> AP["API behaviour"]
I --> RS["rights reach the door"]
I --> DL["a passage reaches the dashboard"]
I --> FL["fleet, telemetry and debug"]
I --> RC["a command reaches the door"]
I --> LD["a burst of passages"]
I --> MT["a cabinet proves who it is"]
I --> TI["two backends, one event"]
I --> CO["retention, tracing and erasure"]
I --> OT["a firmware release reaches the cabinet"] Two things about the shape are worth knowing:
The images are built once and handed around. Building the controller means compiling SQLCipher and a vendored OpenSSL from source; every scenario job paying that separately spent most of a run rebuilding what the job next door was building at the same moment. It also means every scenario tests the same binary, which the old arrangement only assumed.
The socle job proves idempotence rather than asserting it. It applies the migrations and seed, verifies, re-runs them, re-runs the seed under SEED_FORCE, verifies again, and then counts rows: a broken idempotency claim otherwise shows up as duplicated demo data weeks later.
Every job dumps the relevant container logs on failure and tears the stack down whatever happens.
Conventions¶
Code¶
- Code must be simple, and as functional as it can reasonably be.
- Types express intent.
RawValueandNormalizedValueare distinct types so a raw read can never be used as a lookup key by accident.Principalis a union rather than one type with an optional tenant, so confinement cannot be forgotten. - Self-explanatory code over comments. A precise name and a small, single-purpose body cannot drift from the code; a comment can.
- Comments carry the non-obvious "why": an invariant the types cannot express, an ordering constraint, a deliberate deviation. Never a paraphrase of the line below.
- Document contracts, not usage. A function's documentation describes its inputs, its result, its guarantees and its failure modes. It never describes where it happens to be called from today.
- No early-return or early-continue for its own sake.
The two implementations that must agree¶
Two pieces of logic exist on both sides of the wire, and each is pinned by a shared vector:
| What | Cabinet | Cloud |
|---|---|---|
| Value normalisation | wardn-domain/src/credential.rs | backend/src/common/credential.ts |
| The rights fingerprint | wardn-edge/src/store.rs | backend/src/sync/fingerprint.ts |
Changing either one without the other surfaces in production as a credential that never matches, or a cabinet resynchronised on every pass. Both have a test asserting the same literal on each side. Change both, or change neither.
Adding a feature¶
flowchart TD
A["Decide where it belongs:<br/>domain, adapter, backend, dashboard"] --> B["Write the code and its test"]
B --> C["Add or extend a scenario<br/>if a green response would not prove it"]
C --> D["make edge-test · jest · tsc · pnpm build"]
D --> E["Update the chapter this documentation<br/>describes it in"] The last step is not optional. This documentation is meant to be readable on its own and to describe what the software actually does; a feature that changes behaviour and leaves the chapter behind has made the documentation wrong, not merely incomplete.
Adding an environment variable¶
Three places, always:
- read it with a documented default in
config.tsorconfig.rs; - add it to
.env.examplewith the comment saying why the default is what it is; - add it to the configuration reference in 12. Operating.
Adding a migration¶
Migrations are numbered, applied in order, and never edited once merged. db-init runs them all on every start and must stay idempotent. If the change touches grants or partitioning, extend db/checks/verify.sql so make verify covers it.
Working on one piece at a time¶
# The cabinet, on its own: it needs nothing else in the stack.
make edge && make edge-logs
make badge VALUE=BDG-0001
# The backend, against the local socle.
docker compose up -d --wait postgres emqx keycloak
docker compose up db-init
cd backend && pnpm start:dev
# The dashboard, from source with live reload.
make dashboard-dev
The cabinet is the easiest to develop against: it depends on nothing but its own database, which is the whole point of the design.
Debugging¶
make logs # everything
make edge-logs # the cabinet
docker compose logs backend # the cloud
make psql # a database shell
curl localhost:9101/metrics # the cabinet's own metrics
open http://localhost:3000/docs # the generated API reference
Raise the cabinet's verbosity without restarting it. Restarting to read a log loses the moment being investigated:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"level":"debug"}' http://localhost:3000/api/v1/devices/$DEVICE/actions/log-level
And to see raw frames as they arrive, open the debug console on /fleet/<id>/debug. It is time-boxed and stores nothing.
To follow one request through the backend:
OTEL_TRACES_CONSOLE=true docker compose up -d backend
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/zones
# quote the X-Trace-Id from the response in the backend logs
The stack is understood end to end. One more chapter: running it for real.
Next → 14. Deploying to production