Target architecture for the Python backend plus two React web apps: a public storefront and a farm console (ADR-0016). Read ANALYSIS.md first for what the scenario requires.
These are settled. Changing one requires a new ADR in docs/adr/, not a commit.
| # | Decision | Rationale |
|---|---|---|
| D1 | One backend, one database, one domain model. Postgres. No mirrored entities, no sync layer. | "All data interconnected" is a constraint, not a feature; mirroring violates it by construction (ADR-0001) |
| D2 | One pricing engine, a pure deterministic function with no I/O | Enables delta preview (C4), reproducible historical prices, and prevents quote/cost divergence |
| D3 | Backend on-prem on the farm LAN; public storefront exposed through a reverse proxy | Printer control needs LAN; avoids a distributed-system layer for a single site |
| D4 | Two web apps, no desktop. Storefront on internet hosting; console on the farm LAN | Superseded ADR-0004: of the six things only a desktop could supposedly do, five were browser features. See ADR-0016 |
| D5 | Both clients consume a generated TypeScript API client from the backend's OpenAPI schema | A hand-written client restates the server's types and drifts invisibly (ADR-0005) |
| D6 | Slicing is human-gated, and its output (PreparedPlate) is a cached first-class artifact |
Decided; the cache is what keeps human-gating from scaling linearly with orders |
| D7 | Drivers never simulate silently. Failure → Offline + alert. Mock driver refuses to load in production |
A system that fails into fiction can hide a non-functional core indefinitely (ADR-0007) |
| D8 | Alembic is the only schema mechanism. No hand-written upgraders, ever | Two mechanisms means no artefact answers "what is the schema" (ADR-0008) |
| D9 | No runtime plugin loading. Feature modules are ordinary packages toggled by config; only drivers, payment providers, and shipping carriers are pluggable, via entry points | Dynamic loading buys no isolation when the coupling lives in a shared layer (ADR-0009) |
| D10 | Single-tenant now, tenant-safe seams. No hardcoded rates, all config injected, no global singletons holding farm state | "My farm first, product later" |
| D11 | Fleet v2 drives Bambu FDM + AMS only. Elegoo and others are manual driver (tracked, not driven) until their driver lands |
Focus the hardest work; the driver interface stays brand-neutral |
| D12 | RU + EN from day one, ₽, metric. Money is Decimal with an explicit Money type — never float |
Mixing decimal and binary floating point through a cost stack loses money by rounding |
| D13 | A feature is "done" only with a passing test referenced. Status docs mark works / scaffolded / stubbed | Checkbox inflation is the mechanism by which a team stops noticing the core does not work |
┌──────────────────────── farm LAN ────────────────────────┐
│ │
internet │ ┌──────────────┐ MQTT/TLS:8883 ┌──────────┐ │
│ │ │ │◄───────────────────────►│ Bambu │ │
▼ │ │ BACKEND │ FTPS:990 │ printers │ │
┌─────────┐ HTTPS │ │ FastAPI │────────────────────────►│ + AMS │ │
│ reverse │──────────────┼──►│ + workers │ └──────────┘ │
│ proxy │ │ │ │ │
└─────────┘ │ │ Postgres │◄──── WS/HTTP ────┐ │
▲ │ │ Redis │ │ │
│ │ └──────┬───────┘ ┌─────┴──────┐ │
┌────┴─────┐ │ │ │ CONSOLE │ │
│ customer │ │ │ WS/HTTP │ SPA │ │
│ browser │ │ ▼ │ (LAN) │ │
└──────────┘ │ ┌──────────────┐ └────────────┘ │
(bundle served │ │ STOREFRONT │ + fleet, materials │
from internet │ │ SPA │ + order desk, access │
hosting; /api │ │ (tunnelled) │ + kiosk wall │
tunnels back here) │ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
Everything server-side is one deployable unit (API + workers, separate processes, same codebase). Redis is for pub/sub fan-out and task queueing, not as a second source of truth.
backend/
printorian/
core/ # config, db session, Money, units, ids, errors, event bus, auth primitives
contexts/
identity/ # users, roles, sessions, customer accounts
catalog/ # model assets, mesh analysis, SKUs, collections, prepared plates
pricing/ # THE pricing engine — pure, no I/O ◄── most important module
ordering/ # cart, order, options, SLA clock, payment state
inventory/ # material specs, lots, locations, AMS mapping, procurement
fleet/ # printers, capabilities, telemetry, maintenance, service cards
scheduling/ # planner: job × printer matching, wait list, capacity forecast
production/ # job lifecycle, dispatch, prep queue, progress
postproduction/ # stages, tasks, QC, packing, shipping
analytics/ # rollups, amortization, P&L
drivers/ # printer protocol adapters (pluggable)
api/ # FastAPI routers (thin), websocket hub, dependencies, OpenAPI
workers/ # telemetry poller, scheduler tick, notifier, rollups
alembic/
tests/
unit/ contract/ scenarios/ virtualfarm/
Each context is a package with a fixed shape:
contexts/<name>/
__init__.py # the PUBLIC interface — the only thing other contexts may import
models.py # SQLAlchemy ORM
schemas.py # Pydantic DTOs
service.py # use cases (may be split into service/ package)
events.py # events this context emits
policies.py # business rules worth naming
Isolation rules, machine-enforced by import-linter:
- A context may import
coreand other contexts'__init__only. Never another context'smodels/serviceinternals. pricingimports onlycore. It touches no database and no clock.driversimports onlycoreandfleet's public types.apiandworkersmay import any context's public interface; contexts may not importapi.
Cross-context communication is via the public interface for queries, and the event bus for reactions. This is the boundary that makes the contexts real rather than a folder layout.
MaterialSpec vs MaterialLot — a product the shop sells is not a physical reel on a shelf. Conflating them into one Spool entity cannot express the scenario's materials table, where status and location belong to the reel and price belongs to the product.
MaterialSpec (catalog level) |
MaterialLot (physical level) |
|---|---|
PLA Matte / Bambu / Black #101010 |
this specific spool, 743 g remaining |
| properties, density, temps, TDS link | location: stock:shelf-B3 or printer:P1S-02:ams-A:slot-3 |
| buy price per 1000 m | lot number, opened-at, dryness, humidity |
| sell price per cm³ | supplier, purchase order, actual paid price |
The scenario's four status tags — stock / in printer / ordered / none — are a derived rollup over lots per spec. That's exactly why counters above the table work naturally: they are COUNT(*) GROUP BY derived_status.
ModelAsset vs PreparedPlate — the artifact that makes human-gated slicing scale.
PreparedPlate
key: (model_asset, scale, material_spec, printer_profile, plate_layout_hash)
file: 3MF / gcode blob
truth: exact print minutes, exact filament grams per slot, layer count
provenance: sliced_by (user), sliced_at, slicer version + profile version
status: valid | stale (model or profile changed) | rejected
First order of a configuration → prep queue → engineer slices → plate cached. Every later order of that configuration → fully automatic dispatch. This is the single most important structural idea in the system.
Customer ──┐
▼
Order ──── OrderLine ──── PriceSnapshot (breakdown + rate_snapshot_id + engine_version)
│ │
│ ├── ModelAsset (from library or uploaded)
│ ├── options: material spec(s), colors[≤4], scale, finishes[], rush
│ └── PreparedPlate? (null until prep completes)
│
├── promised_at · decay_policy (+ its pinned rates) · sla_credit (columns, not an aggregate)
├── Payment (YooKassa/CloudPayments, ₽, receipt)
│
└── Job* ──── assignment ──► Printer ──── AmsSlot ──── MaterialLot
│ │
│ └── ServiceCard ── MaintenanceOperation (periodicity, materials used)
│
└── PostProductionTask* ──► QcRecord ──► Package ──► Shipment
Roughly 25 aggregates rather than 45 entities. Rule: an entity exists because a use case needs it, and it is introduced in the phase that needs it — not up front.
The delivery promise is three columns, and the aggregate was rejected rather than skipped. An earlier version of this map drew an SlaCommitment emitting a PriceCredit; neither was ever built, and the reason belongs here because the drawing keeps inviting someone to build them. An order has at most one promise and one credit figure, so a second table buys no cardinality — and it would cost the ceiling: sla_credit <= total is a CHECK constraint on orders only because the credit sits on the same row as the total, and on a money path "never credit back more than was collected" is worth more enforced by PostgreSQL than by convention. The credit is recomputed from the promise on every sweep rather than accumulated, so there is no accrual ledger to give a second aggregate a job either. §5 is the mechanism.
The most carefully guarded module in the system. Pure, deterministic, versioned, no I/O.
def price(spec: PriceSpec, rates: RateSnapshot) -> Breakdown: ...PriceSpec — everything that affects price: geometry estimate (or exact values from a PreparedPlate), material spec + colors, scale, finishes, quantity, rush, delivery, customer price-book tier.
RateSnapshot — an immutable, hashed, stored bundle of every rate: material sell prices, electricity ₽/kWh, labor ₽/h, printer amortization, packaging, failure buffer %, overhead, margin, discount ladder, rush %.
Breakdown — a list of typed line items, each carrying its basis so the UI can explain itself:
LineItem(code="labor.print_supervision",
label_ru="Труд · надзор за печатью",
amount=Money("1_680.00", RUB),
basis="4.2 ч × 400 ₽/ч",
category=COST)| Scenario need | How it falls out |
|---|---|
| C3 transparent structure | Breakdown is the structure; UI renders line items with basis text |
| C4 delta preview | diff(price(spec_a, r), price(spec_b, r)) grouped by code → "+120 ₽ labor, −260 ₽ material" |
| Reproducible history | Order stores rate_snapshot_id + engine_version; the exact quote can be recomputed years later |
| Testability | Property tests: monotonic in quantity, discount ladder never inverts, total == Σ items, no float drift |
| Storefront/console agreement | There is one function. There cannot be a second. |
Because slicing happens after checkout (D6), price is quoted from a mesh heuristic and truth arrives later:
EstimateSource: MeshHeuristic → PreparedPlate → Measured(actual telemetry)
Rule: when the prepared plate's cost exceeds the quoted cost by more than variance_tolerance (config, default 15%), the job does not dispatch. It routes to PriceReview with the delta shown. Within tolerance, the quote is binding and the farm absorbs it. Every variance is recorded so the mesh heuristic can be calibrated against real slicer output — the estimator gets measurably better over time.
The promise lives on the order itself: promised_at, plus a named decay_policy drawn from a closed set (standard — 5%/day past a 12-hour grace, capped at 30%; none; strict — 10%/day past 2 hours, capped at 50%). The order stores the policy code and the three numbers behind it — decay_percent_per_day, decay_grace_seconds, decay_max_percent, copied out of POLICIES at placement. It stored only the code until #74, and _credit_for re-read the live dict on every sweep, so editing standard re-priced every promise not yet shipped at the new rate on the next pass. That was ADR-0020's trap on the other money path, surviving because an unshipped order has no pinned breakdown to protect it. The credit is now computed from the pinned copy, and an edit to POLICIES reaches forwards only; the freeze at dispatch below is no longer the only thing protecting a promise. Orders placed before the columns existed carry nulls and keep the old lookup — their terms were never recorded, and a plausible guess would be worse than the gap (ADR-0007). §4.2 says why these are columns rather than an aggregate.
A worker (workers/sla.py) sweeps every order past its promise and still owing work, and recomputes orders.sla_credit from the promise, the policy and the total — recomputed rather than accumulated, so a pass that runs twice or a process that restarts mid-sweep cannot double-count. The clock stops when the parcel leaves: the credit is frozen on the transition to shipped, and no later sweep touches it.
The credit is not a line item in the breakdown and cannot be one — the breakdown is pinned at checkout and never recomputed (ADR-0002, ADR-0020), so a figure that grows after payment has nowhere to live inside it. It settles in two places instead: revenue reads net it off (Order.total - Order.sla_credit, in ordering/finance.py), and where the order is already paid, PaymentsService.refund_sla_credit returns exactly the outstanding amount through the payment provider. Predictable and capped rather than an ad-hoc discount someone applies by hand, and audited. Every movement of the figure appends a row to sla_credit_entries (ordering/credit.py) carrying the previous value, the new one, and the promise and three decay terms it was derived from — written in the same transaction as the column it records, so an entry cannot commit while the column does not. It was not audited until #75: the sweep overwrote orders.sla_credit in place, refresh_sla_credit wrote no order_events row (the only two writers were placement and status transition), and the SlaCreditAccrued it publishes goes to the in-process bus, whose sinks persist nothing — so no prior value of the credit survived anywhere, on a money path. The ledger is a table of its own rather than order_events rows because OrderView eagerly loads an order's events on every read, table() included, and the credit moves on every sweep: at the default sla_sweep_seconds=300 a standard promise moves 1 728 times before it reaches its cap, so one page of twenty late orders would have carried thirty-four thousand event rows.
Also a pure function, so it can be tested against fixtures and run in the virtual farm.
def plan(jobs: list[ReadyJob], printers: list[PrinterState], now: datetime,
policy: SchedulingPolicy) -> list[Assignment | WaitListEntry]: ...Eligibility filter (hard constraints): build volume fits, nozzle diameter compatible, required material type available, required colors present in mounted AMS slots with sufficient remaining grams, printer not in maintenance/error, brand-specific capability (multi-material requires AMS).
Job ordering: due-date risk first, then explicit priority, then job id — the last purely so two runs of the planner cannot disagree about work already acted on.
Scoring (soft, per job, among eligible machines): capability waste (do not spend a
multi-material machine on a single-colour job), material headroom (prefer a spool with
room to spare over one that only just covers the plate), printer amortization rate, then
load balance. Every term is a penalty in [0, 1] scaled by a configured weight, and the
cheapest machine wins.
Corrected in Phase 4. This section originally listed changeover cost and batching affinity as the first two soft terms. Implementing it showed both are unreachable: eligibility already requires the material and every requested colour to be mounted, so every machine the scorer sees has them and both terms are identically zero for every candidate. Weighting a constant cannot change an outcome. They are replaced above by terms that actually discriminate between eligible machines. Changeover becomes a real cost again once jobs can be planned onto machines that are still busy — queue depth, which Phase 4 does not yet do.
Every assignment writes an AssignmentDecision — candidates considered, why each was rejected, the winning score components. "Why did job #4127 go to P1S-03?" must be answerable from the database — which means recording the machines that lost, and why, not just the one that won.
No eligible printer → WaitListEntry with predicted_start from the capacity model,
surfaced in the customer cabinet (C7). Wait-list entries are re-evaluated on every
relevant event, not only on a timer.
predicted_start is set only when time alone will fix the wait — a machine that is
busy with a known finish time. It is None, with a reason, when the job needs filament
nobody has mounted (nothing knows when a person will walk over with a spool) or when no
machine on the farm can print the plate as configured (waiting will never help; a human
must decide). Naming a start time in those cases would be the queue's version of a driver
inventing telemetry for an unreachable printer.
Triggers: scheduler tick (default 30 s) plus event-driven re-plan on job.ready, printer.became_idle, material.mounted, order.priority_changed.
class PrinterDriver(Protocol):
async def connect(self, conn: ConnectionInfo) -> None: ...
async def capabilities(self) -> Capabilities: ...
def telemetry(self) -> AsyncIterator[Telemetry]: ...
async def upload(self, plate: PreparedPlate) -> RemoteFileRef: ...
async def start(self, ref: RemoteFileRef, ams_map: AmsMapping) -> JobHandle: ...
async def pause(self) / resume(self) / cancel(self, reason) -> None: ...| Driver | Transport | Status |
|---|---|---|
bambu |
MQTT over TLS :8883 (LAN access code + serial) for telemetry/commands; FTPS :990 for 3MF upload; SSDP for discovery |
Built in Phase 3 |
manual |
none — state advanced by operators in the UI | Built in Phase 3; this is how Elegoo machines are tracked today |
mock |
in-process virtual printer with configurable timing and failure injection | Built in Phase 0, for the virtual farm |
elegoo (SDCP), moonraker, prusalink |
— | Phase 7, and their purpose is to prove the abstraction isn't Bambu-shaped |
Rules enforced in code:
mockraises at import time ifsettings.env == "production".- A driver returning stale/unavailable data must raise
DriverUnavailable; the fleet context maps that toOfflineand raises an attention event. No fallback data. Ever. - Every driver ships contract tests against recorded protocol fixtures, so a firmware change breaks CI rather than production.
Known risk: Bambu firmware has previously restricted third-party LAN control. Mitigation: (a) protocol spike in Phase 0 against real hardware before anything is built on top; (b) manual driver as a permanent, first-class fallback so a locked-down printer degrades to tracked rather than broken; (c) pin known-good firmware on production machines and test upgrades on one printer first.
An in-process async event bus inside the backend; Redis pub/sub fans out to WebSocket clients. One event schema shared by both web apps, generated into TypeScript alongside the API client.
The Redis half is not optional, and for a while it did not exist. The API and the workers run as separate containers (deploy/compose.prod.yml), each with its own in-process bus, so every event raised by a sweep rather than by a request was published into the worker process and stopped there — telemetry state changes, the SLA clock's credits, the post-production and packaging boards. The console's boards refetch on an event and on connect, never on a timer, so in production they loaded once and then sat still while the farm worked. Nothing failed; the screens were simply wrong, which is why it went unnoticed.
core/relay.py is that fan-out. Every process publishes the live patterns onto one channel tagged with its own origin; the API subscribes and pushes what arrives to its WebSocket clients, skipping its own origin because those events already reached the hub off the local bus. That origin filter is what makes the design work unchanged with Redis absent — local events still reach local clients, and only the cross-process hop is missing — and it is what will let the API run more than one replica when ADR-0003 stops being true.
Redis stays a transport and not a source of truth: nothing is persisted, nothing is replayed, and a dropped frame is covered by the client's resync-on-connect. A relay that cannot reach Redis logs and keeps serving rather than failing the operation that emitted the event. The release gate proves the hop in the arrangement that ships (backend/tools/relay_probe.py).
One redaction. payment.settled carries an amount, and every holder of VIEW_PRODUCTION is entitled to this socket — while the REST API keeps VIEW_FINANCIALS deliberately separate from every production permission. The two disagreed about who may see money. The socket has no per-actor filtering, so the amount is stripped on the one path both sources share (core.relay.REDACTED_FIELDS); a screen that should show money asks the API, which checks the permission.
printer.state_changed job.progress job.finished job.failed
order.stage_changed material.low material.mounted attention.raised
plate.prepared sla.at_risk payment.settled
The personnel dashboard is a subscription over attention.* — "print finished, move to post-production" (C10) is not a polling loop, it is an event with an acknowledgement and an escalation timer.
frontend/
packages/
api-client/ # GENERATED from OpenAPI — never hand-written
events/ # generated event types + typed WS subscription hook
ui/ # shared React components; design tokens; i18n (RU/EN)
domain/ # shared formatting: Money, units, statuses, enum labels
apps/
web/ # Vite SPA: storefront (configurator, cabinet) + light admin
console/ # Farm console: fleet, materials, order desk, access (LAN only)
The scenario asks for the same table pattern at least four times (materials, printers, orders, service operations). It is built once, in packages/ui, and configured per entity:
<DataTable
source={materialsQuery}
columns={materialColumns} // sortable headers
statusTags={materialStatusTags} // counter chips above the table
detail={MaterialDetailDrawer} // popup with full information
actions={[addMaterial, orderMaterial]}
savedViews // filters persist per user
/>The scenario asks for this table at least four times — materials, printers, orders, service operations. Building it four times is how each one becomes 500 lines and how the four end up behaving differently.
Both are ordinary React SPAs on packages/ui, same-origin with the API, sharing
one session cookie.
Storefront (apps/web) — catalogue, configurator, checkout, cabinet, journal.
Its bundle is served from internet hosting, which reverse-proxies /api back to
the farm. That machine holds no data at rest.
Console (apps/console) — fleet, materials, order desk, prep, access,
finances, analytics. Served by the on-prem server on the LAN, and never reachable
from the storefront: staff screens are absent from that bundle rather than hidden
inside it.
What the desktop app used to justify itself with, and where each went:
| Was | Now |
|---|---|
| LAN printer discovery | Server-side — the backend is already on the LAN |
| NAS/local model library | Server-side, behind model storage |
| Launching the slicer, watching a folder | A download/upload round trip (ADR-0006, amended). The only genuinely native capability, and one is not a desktop app |
| Kiosk/wall mode | chrome --kiosk on a console route |
| Native notifications | Web Notifications API |
| Barcode/QR input | HID scanners type; camera scanning is getUserMedia |
Security: the console's reach is the API's permission matrix, enforced server- side. Being unreachable from the internet is a deployment fact, not a boundary — role gates live in the API layer, because hiding financials in a template is not a security boundary.
| Concern | Decision |
|---|---|
| Auth | Session cookie for both apps — each is same-origin with the API, the storefront behind the tunnel and the console on the LAN. Roles: customer, operator, engineer, manager, owner. Role gates live in the API layer, not the UI — hiding a control is not a security boundary |
| Money | Decimal + explicit Money(amount, currency). Rounding rules centralized. Floats are banned in pricing by lint rule |
| i18n | RU + EN. Backend returns codes and structured data; clients render labels from message catalogs. No user-facing strings hardcoded in the backend |
| Files | Model assets and plates on the filesystem/NAS behind an ObjectStore interface (local now, S3-compatible later); DB stores references + hashes only |
| Time | UTC everywhere internally; farm timezone applied only at presentation and in open-hours rules |
| Migrations | Alembic only. One head. Migration tests run on every CI build against a fresh and a seeded database |
| Observability | Logging works. Structured logs — JSON in production, plain text elsewhere — every line carrying the correlation id of the request that produced it, taken from the caller's X-Request-ID when there is one so a trace begun at the proxy continues, and echoed back so a person reporting a problem can quote it (api.middleware). One access line per request with its duration, which is the only signal here that can see a slow request rather than a slow query. Health works, and the four endpoints answer four different questions. /health touches nothing. /health/ready names each dependency separately so an outage names its own cause: database, telemetry_partitions, assignment_records, printers_listing, materials_listing, wal_archiving, and — only where a relay is configured at all — event_relay. Only database can come back failed, and only that returns 503; the rest report degraded at 200, because a broken backup, a quiet live board or a table that has outgrown its shape must not be turned into a broken farm. assignment_records reports a threshold rather than a fault, which is what makes it different from wal_archiving beside it: the table ADR-0018 deliberately left unpartitioned, measured out of the catalogue against the 10M-row / 20 GiB trigger, and once crossed it stays lit until the table is split rather than clearing when a condition passes. The two *_listing checks are the third shape again: the listings DATABASE-REVIEW §9 left unpaged, counted against a 500-row trigger by a count that stops at the trigger so the probe cannot become the cost, and clearing on their own — a set that can shrink, unlike a table that has already been written. Drivers are deliberately not among them — the pool of live printer connections belongs to the worker process (workers/drivers.py, kept alive between passes so a fifty-machine farm does not reconnect every tick), so the API has no connection state of its own to report and a readiness check here would have to invent one. They are reported at /health/workers instead, which is where the same argument leads rather than where it stops: the worker publishes what it observed down the channel the heartbeat already uses (core.driver_health), and the API reports it without pretending to own it. Per printer and never as a count, with the error code and when the state began — an outage that cannot name its own cause is one somebody has to go and find. A printer the worker named and then stopped publishing readings for reports unknown rather than ok (ADR-0007), which is why the roster outlives the readings it names. A driver that is down never changes a status code: not readiness, where an unreachable printer would take the whole API out of rotation, and not /health/workers either, where it would sit permanently red on any farm with a machine switched off and destroy the signal the endpoint exists for. Only the loops decide that, and an alert about a driver keys on the body. /health/workers covers the seven sweeps by name, from a beat each records at the end of a pass, and is kept out of readiness on purpose: a wedged sweep is a reason to alert, not to roll a release back. /health/printing is the fourth, and the only one here whose consumer is neither a screen nor a container runtime: deploy/reboot-guard.sh on the host asks it before unattended-upgrades or a UPS shutdown cuts power, so the status code is the verdict and it fails closed — 200 only for a reading that was taken and came back with nothing on a machine, and 503 for a farm that is busy and for a database that could not be read alike. Those two share a code and differ in the body on purpose, so that a guard simple enough to be trusted defers without parsing anything; the unreadable case answers in_flight: null and never three zeros, because three zeros read as an idle farm on the one path where that authorises pulling the plug (ADR-0007). DISPATCHING vetoes and ASSIGNED does not — a half-sent plate file needs a person, while an assignment is a plan the planner re-makes after a restart, and a job wedged in ASSIGNED would hold the veto on for ever and the host would never patch again. The trigger half is deliberately absent: nothing in this repository reboots anything, and deploy/systemd/README.md says what would have to be shown on a real host first. Metrics have started. GET /metrics serves a Prometheus text exposition (core.metrics, prometheus-client), unauthenticated like the probes, built on a registry per scrape rather than the process-global default so two create_app instances in one test session cannot accumulate each other's series. It carries three of INFRASTRUCTURE §5's ten domain metrics — the three whose data the API process already measures: printorian_printers_offline{printer,brand}, printorian_telemetry_partition_months_ahead, printorian_wal_archive_failures_total. ADR-0007 is structural here rather than remembered: observe(value=None) sets nothing, so a reading nobody took is an absent series and never a zero — a driver reporting unknown produces no sample, an unpublished roster produces a family with no samples, and a database that cannot be reached leaves both DB-backed series out while the endpoint still answers 200 with what is known. No money on this surface, and that is a decision rather than an oversight: printorian_sla_credit_accrued_rub waits until the scrape has an identity, because VIEW_FINANCIALS cannot be checked on a caller there is none of. Still missing, and none of it is close: the worker exporter, the four remaining database-backed collectors, the backup and restore-drill stamps (no machine-readable marker is written today), the scrape-auth decision behind the money series, and the whole VictoriaMetrics/Grafana/Alertmanager host stack. Note the collision before concluding otherwise: GET /fleet/metrics and /fleet/metrics/{printer_id} are the farm's measured occupancy behind VIEW_PRODUCTION, not an instrumentation scrape. Stage 5 |
| Blocking work | Nothing that computes for a second runs on the event loop. The API is one process, so a second spent parsing a mesh is a second in which it serves nothing — core.cpu runs that work in a bounded pool of threads, and the limit is injected rather than assumed |
| Rate limits | Ceilings on the endpoints whose cost is the problem, and a lockout in front of password guessing (core.ratelimit). POST /pricing/quote takes an optional actor, so it is reachable without signing in and every call parses a mesh. In-process, which is correct while ADR-0003 holds and is recorded in DATABASE-REVIEW §9 as a trade-off rather than a fact |
| Request bodies | Refused by size at the ASGI layer, before anything buffers them (api.middleware.BodySizeLimitMiddleware). A check inside a handler runs after python-multipart has already parsed the whole body, so it cannot decline to spend the memory |
| Backup | pg_dump + object-store snapshot on a schedule, with a tested restore procedure — untested backups are not backups |
Mess is not prevented by intention; it is prevented by CI failing.
| Rule | Enforcement |
|---|---|
| Context isolation | import-linter contracts in CI |
pricing has no I/O |
import contract: pricing may import only core; no sqlalchemy, no datetime.now |
| No file over 400 lines | ruff/custom lint gate |
| Types | mypy --strict on contexts/ and drivers/ |
| No second pricing/scheduling implementation | code-owner review on contexts/pricing and contexts/scheduling |
| API client never hand-edited | generated file, CI regenerates and fails on diff |
| Feature marked done ⇒ test exists | PR template requires the test reference (D13) |
| Every driver has contract tests | CI gate: a driver without fixtures does not merge |
| Virtual-farm E2E passes | CI gate on every PR — order → prep → dispatch → print → post-production → ship, with mock printers |
The last one deserves emphasis: the virtual farm is built in Phase 0, before the features it tests. It is what lets the scheduler and the order pipeline be developed and regression-tested without hardware, and it is what makes a silently-simulating connector impossible to ship: simulation has a name, a driver, and a refusal to load in production.