Skip to content

12. Operating · Contents

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. RawValue and NormalizedValue are distinct types so a raw read can never be used as a lookup key by accident. Principal is 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:

  1. read it with a documented default in config.ts or config.rs;
  2. add it to .env.example with the comment saying why the default is what it is;
  3. 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