Architecture

On this page 7

The repository is a Stacks application. It serves three surfaces from one codebase: the marketing site, the farmer console, and the public API. All three read the same data through one layer, which is the single most important property of the design.

The path a fact takes

app/Support/content/*.ts        authored content, the source of truth

        │  buddy catalog:sync   truncates and rewrites

database (features, use_cases, farms, fields, missions,
          detections, treatment_maps)

        │  app/Support/catalog.ts   the one read layer

   ┌────────────────┬────────────────────┐
   │ resources/views│ app/Actions/Catalog│
   │  (the site)    │   (the API)        │
   └────────────────┴────────────────────┘

Nothing renders a figure it typed itself. If /features and /api/features ever disagree, that is a bug rather than a stale copy.

What lives where

PathWhat lives there
app/Support/content/Authored content: 19 capabilities, 16 use cases, and the demonstration field's generator
app/Support/catalog.tsThe shared read layer. Every page and every endpoint goes through it
app/Support/capabilities.tsThe bridge from the marketed catalog to what one holding has switched on
app/Support/dashboard.tsThe farmer's own holdings, scoped by farms.user_id
app/Support/fieldmap.tsRenders a field to SVG, server side, from the flight record
app/Models/Twelve models: Feature, UseCase, the operational domain (Farm, Field, Drone, Mission, Detection, TreatmentMap, FarmCapability, Herd, HerdMove) and DemoRequest
app/Actions/Catalog/The public read API
app/Actions/Dashboard/The console's four writes
app/Actions/Leads/The field-visit booking endpoint
app/Commands/catalog:sync, imagery:attach, demo:account, og:images
app/Jobs/ScheduleCapabilityFlights.tsTurns a standing cadence into flights that are due
app/Middleware/FarmScope.tsConfines every generated REST call to the caller's own holdings
resources/views/The site. features/[slug].stx and use-cases/[slug].stx render every detail page
public/site.cssDesign tokens, and the CSS that utilities cannot express
config/cloud.tsDeploy configuration: a tenant on the shared Stacks box

The domain model

Twelve models, of which ten describe the operation.

Farm ──┬── Field ──┬── Mission ──┬── Detection
       │           │             └── TreatmentMap
       │           │
       ├── Drone ──┘
       └── FarmCapability
ModelHoldsNotes
FarmThe holding: name, region, hectares, segment, user_idThe demonstration farm's user_id is null on purpose, so it appears on no dashboard
FieldThe parcel: crop, hectares, boundary ring, latitude and longitudeboundary is a normalised 0..1 ring, not a projected polygon
DroneThe aircraft in the fleetWhich drone flies is decided at dispatch, not at planning
MissionOne flight: purpose (a feature slug), status, flown_at, resolution_cm, duration_minutes, and the orthomosaic fieldsstatus is one of scheduled, flying, processing, complete, weather_cancelled, failed
DetectionOne finding: kind, label, x, y, area_m2, severity, confidence, statuskind is one of weed, disease, pest, nutrient, moisture, compaction, wildlife, gap, livestock
TreatmentMapThe prescription: zone geometry and treated_hectaresThe thing a sprayer actually loads
FarmCapabilityA standing instruction: feature slug, status, cadence_days, optional field_idstatus is active, paused or requested

Mission.purpose carries a feature slug. That is what connects the capability catalog on the marketing site to the flights that actually happened over a field, with no join table in between.

Denormalised keys are deliberate

Mission.farm_id and Detection.field_id are both derivable by joining. They are stored anyway so a holding's flights and a field's findings are each one query. The seeder fills a declared key that comes out empty from the parent it points at.

Multi-tenancy

Two separate mechanisms, because there are two separate surfaces.

The console never accepts a farm id from the request. Every dashboard read re-derives the holding from the signed-in user through farmFor(userId), and every dashboard write does the same and ignores any farm the request names. A request cannot name a holding, therefore it cannot name somebody else's.

The generated REST endpoints, which come from the models' useApi traits, know nothing about tenancy on their own. app/Middleware/FarmScope.ts narrows them: a write carrying farm_id must name a farm the caller owns, and a read without one is answered with ?farm_id= pinned to their own holding. An account with no holding gets a 403 rather than an empty list, because an empty list reads as "you have nothing" rather than "you have not started yet".

The demonstration farm is unowned, which is what keeps the worked example out of somebody's real numbers.

Scheduling

app/Scheduler.ts runs ScheduleCapabilityFlights daily at 05:30 Europe/Berlin. It converts each active FarmCapability into planned Mission rows for the fields whose cadence has come round.

Three things it deliberately does not do:

  • It does not schedule requested capabilities. Those need equipment on site or a licence check first, so a planned flight would be a promise the schedule cannot keep. See Capabilities that need a visit.
  • It does not double-book. A field with an existing scheduled flight for the same capability is skipped, so a retry does not fill the calendar.
  • It does not decide the aircraft.

Daily rather than hourly because cadences are measured in days.

Failure posture

Every dashboard query is guarded individually with a safely() wrapper that falls back rather than throwing. A dashboard that 500s because one panel's table has not been migrated is worse than a dashboard with one empty panel. The same reasoning applies in catalog.ts: an unseeded instance serves an empty catalog rather than an error page.