411b08d02f
Substantial design artifact + canonical-source ingest for the TRM business plane. Schema draft (synthesis): - wiki/synthesis/directus-schema-draft.md — working agreement for the multi-tenant schema. Pseudo multi-tenant under organizations; entries as the unit of timing; course definition (stages/segments/geofences/ waypoints/SLZs); penalty system "numbers in DB, math in code" with an evaluator registry and progressive bracket math; per-entry timing tables; per-stage start-order strategies (manual / previous_stage_clean_result / inverse_top_n_then_natural / inverse_of_overall) covering both Tirana 24h and Rally Albania patterns. Two role surfaces (org role vs racing role) called out explicitly. Decisions captured; Open questions reduced to one (geometry retroactivity engine, deferred to Phase 2.5). Source ingest: - raw/Regulations_2025.pdf + wiki/sources/rally-albania-regulations- 2025.md — formal ingest of the canonical Rally Albania 2025 rulebook. Section numbers preserved as §X.Y so the schema draft and future SPA work can cite precisely. Flagged follow-ups: the SLZ formula lives in the Supplementary Regulations (don't hardcode); M-7 numbering bug; unmodeled neutralization zones. Faulty-position flag (cross-plane operator workflow): - entities/postgres-timescaledb.md, entities/processor.md, concepts/position-record.md — operator-controlled boolean on the positions hypertable; processor filters WHERE faulty = false on every read; flagging triggers windowed recompute via the recompute:requests stream. Implementation strategy on entity pages: - entities/directus.md — Schema management section documenting the snapshots/ + db-init/ convention, container-startup apply pipeline. - entities/processor.md — Phase 2 long-lived branch model with PROCESSOR_PHASE_2_ENABLED flag-gating for incremental main merges; Phase 2.5 deferral note. Index and log updated.
488 lines
29 KiB
Markdown
488 lines
29 KiB
Markdown
---
|
||
title: Directus Schema — Working Draft
|
||
type: synthesis
|
||
created: 2026-05-01
|
||
updated: 2026-05-01
|
||
sources: [rally-albania-regulations-2025]
|
||
tags: [directus, schema, business-plane, draft, penalties, geofences]
|
||
---
|
||
|
||
# Directus Schema — Working Draft
|
||
|
||
> Status: **working agreement**, not final. Captured during the 2026-05-01 schema discussion. Open for additions and revisions as the domain shape clarifies.
|
||
|
||
The TRM business-plane schema, as worked through so far. Pseudo multi-tenant under `organizations`. Two layers: an org-level catalog of durable resources (users, teams, vehicles, devices) and a per-event participation layer (entries + their crew/devices). All event-scoped state hangs off `entries`; `entries` is the unit of timing.
|
||
|
||
> **Reference rulebook:** [[rally-albania-regulations-2025]] is the canonical real-world reference. Section numbers cited in this doc as `Rally Albania §X.Y` map to that source. Where the schema needs a federation-specific shape (start-order rules, penalty taxonomy, class catalog), that source is the ground truth.
|
||
|
||
## Tenancy model
|
||
|
||
`organizations` is the root. Users, teams, vehicles, and devices are **all m2m with orgs** — a single device can be loaned across orgs, a privateer can race for two clubs, a team can compete under multiple federations. Events, by contrast, are scoped to a **single org** (one FK).
|
||
|
||
## Org-level catalog
|
||
|
||
Durable resources that exist independently of any particular event.
|
||
|
||
### `organizations`
|
||
|
||
Tenant root. Every business-plane row ultimately traces back here.
|
||
|
||
### `users`
|
||
|
||
Directus users, augmented with TRM-specific profile fields. M2M with orgs via `organization_users`.
|
||
|
||
### `organization_users` (junction)
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `organization_id` | FK |
|
||
| `user_id` | FK |
|
||
| `role` | enum: `owner` / `admin` / `race-director` / `marshal` / `participant` |
|
||
|
||
The `role` column drives Directus permission policies — a user's effective permissions in a given org come from this row.
|
||
|
||
### `teams`
|
||
|
||
Durable rosters. M2M with orgs via `organization_teams`. A team's "presence" in an event is derived from `entries.team_id` — no separate team↔event join needed.
|
||
|
||
### `organization_teams` (junction)
|
||
|
||
`organization_id`, `team_id`.
|
||
|
||
### `team_members` (junction)
|
||
|
||
`team_id`, `user_id`. Durable team roster. Who actually races for the team in a given event is the subset that has entries with that `team_id`.
|
||
|
||
### `vehicles`
|
||
|
||
M2M with orgs via `organization_vehicles`. No ownership FKs — ownership is a real-world fact that doesn't affect timing or tracking, and which user/team a vehicle "belongs to" is fluid (factory loaner, rented, privateer, swapped between teams). Anyone with org-level entry-creation permission can register any org vehicle into an entry.
|
||
|
||
### `organization_vehicles` (junction)
|
||
|
||
`organization_id`, `vehicle_id`.
|
||
|
||
### `devices`
|
||
|
||
GPS hardware (Teltonika today, future vendors possible). M2M with orgs via `organization_devices`. The `imei` is the canonical identifier the [[tcp-ingestion]] service uses; the Directus row carries vendor/model/firmware metadata and the org bindings.
|
||
|
||
### `organization_devices` (junction)
|
||
|
||
`organization_id`, `device_id`.
|
||
|
||
## Event-level participation
|
||
|
||
The per-race shape. Built around `entries` as the unit of timing.
|
||
|
||
### `events`
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `organization_id` | FK, single-org per event |
|
||
| `discipline` | enum: `rally` / `regatta` / `trail-run` / `hike` / … (extensible) |
|
||
| `name`, `starts_at`, `ends_at`, … | descriptive |
|
||
|
||
`discipline` drives validation: which crew roles are valid, whether vehicles are required, which device mount points the UI offers.
|
||
|
||
### `classes`
|
||
|
||
Per-event class taxonomy. `event_id` FK, plus `name`, `code` (e.g. T1, T3, SSV), `sort_order`. Each event defines its own class set, so a rally's T1/T2/T3 doesn't pollute a regatta's classes.
|
||
|
||
### `entries`
|
||
|
||
The unit of timing — what gets a bib, gets timed, gets a result.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `event_id` | FK |
|
||
| `vehicle_id?` | nullable — null for foot/hike events |
|
||
| `team_id?` | nullable — null for lone racers |
|
||
| `class_id` | FK to `classes` |
|
||
| `bib` | per-event identifier shown to spectators |
|
||
| `status` | enum: `registered` / `started` / `finished` / `dnf` / `dns` / `dsq` / `withdrawn` |
|
||
|
||
Status semantics:
|
||
|
||
- `registered` — entered, not yet started.
|
||
- `started` — currently competing. Live map only renders entries with this status.
|
||
- `finished` — completed within the rules. Eligible for ranked results.
|
||
- `dns` (Did Not Start) — registered but never crossed the start line.
|
||
- `dnf` (Did Not Finish) — started but failed to complete (mechanical, retired, timeout). Shows on results board but unranked.
|
||
- `dsq` (Disqualified) — penalized off the result by officials.
|
||
- `withdrawn` — pulled themselves out before the event started; doesn't count toward field size.
|
||
|
||
A vehicle can race at most one entry per event (matches reality — same vehicle can't run two stages simultaneously). For foot races, `vehicle_id` is null and the entry is identified by its single crew member.
|
||
|
||
### `entry_crew` (junction)
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `user_id` | FK |
|
||
| `role` | enum: `pilot` / `co-pilot` / `navigator` / `mechanic` / `rider` / `runner` / `hiker` (extensible per discipline) |
|
||
|
||
One row for solo rides, four+ for big-truck rally crews. No separate `crews` collection — `teams` already provides durable group identity, and per-entry crew is the subset that showed up.
|
||
|
||
#### A user has two distinct role surfaces
|
||
|
||
The schema carries two `role` columns that look similar but mean different things. Easy to conflate; worth being explicit.
|
||
|
||
| Surface | Where | Scope | What it controls |
|
||
|---|---|---|---|
|
||
| **Org role** | `organization_users.role` | Per-tenant, durable | What the user can see and do inside an org. Drives Directus policies (race-director, marshal, participant, …). |
|
||
| **Racing role** | `entry_crew.role` | Per-entry, per-event | What the user does on the track in this specific entry (pilot, co-pilot, navigator, mechanic, …). |
|
||
|
||
The two role enums don't overlap. Same user can be `race-director` in Org A (admin power), `participant` in Org B, and show up as `pilot` in three different entries across both orgs — three independent dimensions.
|
||
|
||
**Crew → org chain** is indirect:
|
||
|
||
```
|
||
entry_crew.entry_id → entries.id
|
||
entries.event_id → events.id
|
||
events.organization_id → organizations.id
|
||
```
|
||
|
||
The crew row itself doesn't reference org. The schema does **not** enforce that an `entry_crew.user_id` is also in `organization_users` for the entry's host org — guest racers happen. If a federation requires strict org membership for participation, that's a per-org permission rule on `entry_crew` insert, not a schema FK.
|
||
|
||
### `entry_devices` (junction)
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `device_id` | FK |
|
||
| `assigned_user_id?` | nullable — null = vehicle-mounted (hardwired/backup), set = body-worn on that crew member |
|
||
|
||
A rally Toyota with three devices: two rows with null `assigned_user_id` (hardwired + backup), one row with `assigned_user_id` set to the pilot (panic button). A solo runner: one or more rows all with `assigned_user_id` set to the runner.
|
||
|
||
## Course definition
|
||
|
||
The spatial and procedural definition of an event. Three layers: stages (ordered containers), segments (typed sub-units of a stage), and geographical features (geofences, waypoints, SLZs) attached to segments.
|
||
|
||
### `stages`
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `event_id` | FK |
|
||
| `name` | "Stage 1", "Day 2 Loop", etc. |
|
||
| `sort_order` | integer |
|
||
| `role` | enum: `prologue` / `regular` / `epilogue`. Default `regular`. Drives default seeding strategy and standings handling. |
|
||
| `starts_at` | base time used to compute per-entry start offsets |
|
||
| `start_interval_seconds` | gap between consecutive starts. Set per stage (Rally Albania §5.10 — decided the day before each stage). |
|
||
| `start_order_strategy` | enum: `manual` / `previous_stage_result` / `previous_stage_clean_result` / `inverse_top_n_then_natural` / `inverse_of_overall`. See "Start order strategies" below. |
|
||
| `start_order_strategy_params?` | JSON. Strategy-specific (e.g. `{ "n": 20, "source_stage_id": <prologue_stage_id> }` for `inverse_top_n_then_natural`). |
|
||
| `start_order_input_stage_id?` | FK to another `stages` row when a strategy reads from a specific previous stage (prologue → stage 1, or any non-immediate predecessor). Null = use immediately preceding stage by `sort_order`. |
|
||
|
||
A rally can have many stages; a trail run typically has one. The stage is just an ordered container — all the actual rules and geometry hang off its segments.
|
||
|
||
#### Start order strategies
|
||
|
||
Different federations seed each stage differently. The strategy is **declarative on the stage**; the [[processor]] (or a Directus operation) materializes `entry_segment_starts` rows when a stage is opened. Real-world cases derived from [[rally-albania-regulations-2025]] §5.5–§5.10.
|
||
|
||
| Strategy | Behavior | Real-world example |
|
||
|---|---|---|
|
||
| `manual` | Org sets each entry's `start_position` directly. No computation. | Tirana 24h every leg; Rally Albania prologue. |
|
||
| `previous_stage_result` | Order by ascending SS time of the input stage (penalty-adjusted excluded). | Rally Albania Stage 1 cars/SSV (input = prologue). |
|
||
| `previous_stage_clean_result` | Same as above, explicitly using clean SS time only. Penalties never affect seeding. | Rally Albania stages 2 → last (input = previous stage). |
|
||
| `inverse_top_n_then_natural` | Top *n* of input stage in **inverse** order, then *n+1*…last in natural order. | Rally Albania Stage 1 bikes/quads (params: `n=20`, source=prologue). |
|
||
| `inverse_of_overall` | Order by descending overall standings after the previous stage. | Rally Albania epilogue. |
|
||
|
||
Seeding input is always **clean SS time** (`stage_results.clean_time`) — penalties roll into overall total, never the next stage's grid (Rally Albania §5.8 explicit on this).
|
||
|
||
Strategies seed **per category** independently. Bikes/quads share a grid; cars share another; SSVs another (Rally Albania §2.8 + §5.10). The materialization step iterates each category's entries and assigns positions within that category.
|
||
|
||
Late-arrival reseeding stays operator-driven (Rally Albania §9.8, Tirana 24h §8.7) — Race Marshals adjust individual `entry_segment_starts.target_at` rows on the day, not via strategy.
|
||
|
||
### `segments`
|
||
|
||
The atomic unit of rules within a stage. Each segment has a type that drives processor and validation behavior.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `stage_id` | FK |
|
||
| `sort_order` | integer |
|
||
| `type` | enum: `liaison` / `special-stage` / `parc-ferme` |
|
||
| `entry_geofence_id` | FK to `geofences` — where you arrive / get timed in |
|
||
| `exit_geofence_id?` | nullable — only `special-stage` segments have one (timed exit). Liaisons end implicitly when the next SS-start is hit; parc fermé has no exit. |
|
||
| `target_duration_seconds?` | nominal duration used to compute target arrival times for liaisons |
|
||
|
||
Concrete shape of a rally stage (per Rally Albania pattern):
|
||
|
||
```
|
||
[Stage Start] → Liaison → [SS1 Start] → SS1 → [SS1 Finish] →
|
||
Liaison → [SS2 Start] → SS2 → [SS2 Finish] →
|
||
Liaison → [Parc fermé]
|
||
```
|
||
|
||
The `[…]` markers are geofences shared between segments — the SS1-Start geofence is both the exit-trigger of the preceding liaison and the entry-trigger of SS1.
|
||
|
||
### `geofences`
|
||
|
||
Reusable spatial assets. Polygons stored as PostGIS geometry.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `event_id` | FK |
|
||
| `name` | display name |
|
||
| `kind` | enum: `stage-start` / `ss-start` / `ss-finish` / `parc-ferme` / `manual-checkpoint` |
|
||
| `geometry` | PostGIS polygon |
|
||
| `manual_verification` | bool — true = marshal-confirmed (CP), false = pure GPS detection |
|
||
| `retroactive` | bool, default `false` — whether geometry edits trigger recompute of past crossings |
|
||
|
||
"Checkpoints" are not a separate collection; they are geofences with `manual_verification = true`. In practice these usually coincide with SS-start / SS-finish geofences (matches rally conventions).
|
||
|
||
### `waypoints`
|
||
|
||
Auto-detected points along an SS track. Missing one = penalty.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `segment_id` | FK — must reference a `special-stage` segment |
|
||
| `location` | PostGIS point |
|
||
| `tolerance_meters` | radius for "passed within" detection |
|
||
| `sort_order` | integer |
|
||
|
||
### `speed_limit_zones`
|
||
|
||
Distinct from `geofences` because they carry SLZ-specific evaluation parameters. Polygons where a speed cap applies.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `segment_id` | FK |
|
||
| `geometry` | PostGIS polygon |
|
||
| `max_speed_kmh` | the cap |
|
||
| `evaluation_window_meters?` | null = whole-polygon peak; `2000` = re-evaluate every 2km of transit (per the Tirana 24h rulebook). |
|
||
| `retroactive` | bool, default `false` — whether geometry edits trigger recompute of past traversals |
|
||
|
||
## Penalty system
|
||
|
||
Penalty rules are stored as data, evaluated in code. The collection holds the *numbers* (per-bracket multipliers, per-miss penalties); the [[processor]] holds the *math* (one evaluator function per penalty type, in a registry).
|
||
|
||
### `penalty_formulas`
|
||
|
||
The collection. Single shape covering bracketed (SLZ) and flat (CP, WP, late-start) rules; bracket-specific fields are nullable.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `belongs_to_type` | enum: `event` / `stage` / `speed_limit_zone` / `geofence` — the scope this rule attaches to |
|
||
| `belongs_to_id` | FK to that scope |
|
||
| `type` | enum: `speed_limit_offence` / `waypoint_missing` / `checkpoint_missing` / `early_start` / `late_start` |
|
||
| `input` | descriptor of the variable being measured (e.g. `peak_overspeed_kmh`, `missed_count`, `seconds_late`) |
|
||
| `offence_min?` | bracket lower bound (km/h, seconds, etc.). Null for flat rules. |
|
||
| `offence_max?` | bracket upper bound. Null = open-ended. |
|
||
| `operator` | enum: `multiplication` / `addition` |
|
||
| `penalty` | the numeric value (sec/kmh, sec per miss, etc.) |
|
||
| `retroactive` | bool, default `true` — whether formula edits trigger recompute of past penalties |
|
||
| `enabled` | bool, default `true` |
|
||
|
||
Resolution at evaluation time: most-specific scope wins. The [[processor]] queries "what's the SLZ rule for this zone?" first against `speed_limit_zone`, falls back to `stage`, falls back to `event`.
|
||
|
||
### Bracketed rules: progressive slice-by-slice
|
||
|
||
For `speed_limit_offence`, multiple rows with the same `(belongs_to_type, belongs_to_id, type)` form a bracket table. Each bracket contributes only to the portion of the input within its range — same math as progressive income tax. For peak overspeed `P`:
|
||
|
||
```
|
||
penalty = 0
|
||
for each row in brackets ordered by offence_min:
|
||
if P < row.offence_min: continue
|
||
upper = min(P, row.offence_max ?? P)
|
||
slice = upper - row.offence_min + 1
|
||
penalty += slice * row.penalty
|
||
```
|
||
|
||
Worked example (Tirana 24h table, peak `P = 58` km/h overspeed):
|
||
|
||
| offence_min | offence_max | sec/kmh | slice | contribution |
|
||
|---|---|---|---|---|
|
||
| 1 | 10 | 5 | 10 | 50 |
|
||
| 11 | 20 | 10 | 10 | 100 |
|
||
| 21 | 30 | 30 | 10 | 300 |
|
||
| 31 | 40 | 90 | 10 | 900 |
|
||
| 41 | null | 240 | 18 | 4320 |
|
||
| | | | **Total** | **5670** |
|
||
|
||
### Flat rules: one row, one number
|
||
|
||
For `waypoint_missing` and `checkpoint_missing`, a single row carries the per-miss value:
|
||
|
||
```jsonc
|
||
// missed waypoint: 60 min × count
|
||
{ "belongs_to_type": "event", "belongs_to_id": "<event-id>",
|
||
"type": "waypoint_missing", "penalty": 3600, "enabled": true }
|
||
|
||
// missed checkpoint: 120 min × count + worst_valid_time_in_category (added by evaluator)
|
||
{ "belongs_to_type": "event", "belongs_to_id": "<event-id>",
|
||
"type": "checkpoint_missing", "penalty": 7200, "enabled": true }
|
||
```
|
||
|
||
The "worst valid time in your category" addition for `checkpoint_missing` is **not** stored as a number. It's a runtime aggregate the [[processor]] computes from `entry_results` at stage close, and the evaluator function for that type adds it as part of its semantics. If a federation publishes a variant without that addition, that's a new `type` (`checkpoint_missing_flat`) with its own evaluator — not a flag on the row.
|
||
|
||
### Code side: the evaluator registry
|
||
|
||
The [[processor]] holds a registry mapping `type` → evaluator function. The evaluators contain no constants — all numbers come from `penalty_formulas` rows.
|
||
|
||
```ts
|
||
const evaluators = {
|
||
'speed_limit_offence': (peak, rules) => walkBrackets(peak, rules),
|
||
'waypoint_missing': (count, [rule]) => count * rule.penalty,
|
||
'checkpoint_missing': (count, [rule], ctx) => count * rule.penalty + ctx.worstValidTimeInCategory,
|
||
'late_start': (secondsLate, rules) => walkBrackets(secondsLate, rules),
|
||
// …
|
||
};
|
||
```
|
||
|
||
**The split:** numbers live in the database (rows in `penalty_formulas`); math shape lives in code (one function per `type`); runtime aggregates (peak speed, missed count, worst time) are computed by the [[processor]] and passed to the evaluator. Adding a new event with a different bracket table = pure data change, no deploy. Adding a fundamentally new kind of math = small code change (one new evaluator, one registry entry).
|
||
|
||
### Loading and reload
|
||
|
||
The [[processor]] queries enabled formulas in scope when an event becomes active or when it starts up:
|
||
|
||
```sql
|
||
SELECT * FROM penalty_formulas
|
||
WHERE belongs_to_id IN (
|
||
$event_id,
|
||
SELECT id FROM stages WHERE event_id = $event_id,
|
||
SELECT id FROM geofences WHERE event_id = $event_id,
|
||
SELECT id FROM speed_limit_zones WHERE event_id = $event_id
|
||
)
|
||
AND enabled = true;
|
||
```
|
||
|
||
Indexes them in memory by `(belongs_to_type, belongs_to_id, type)` for O(1) lookup at violation time. Reload triggers: Directus webhook on formula edit (preferred), periodic refresh as fallback, manual "reload formulas" admin action.
|
||
|
||
### Retroactive recompute
|
||
|
||
`retroactive` lives on `penalty_formulas` (default `true` — math fixes usually apply across the field) and on `geofences` / `speed_limit_zones` (default `false` — physical crossings stand on their own merit). Per-edit override available so an organizer can flip the default for a specific change.
|
||
|
||
When a row is updated and `retroactive = true`:
|
||
|
||
1. A Directus Flow / hook enqueues a recompute job (Redis Stream `recompute:requests`).
|
||
2. The [[processor]] consumes the job, walks affected `entry_penalties`, recomputes each from snapshotted inputs against the new formula, updates the rows in place, logs `recomputed_at` / `recomputed_reason`.
|
||
3. Old values are preserved in Directus revisions for audit.
|
||
|
||
Two recompute kinds:
|
||
|
||
- **Formula change** — cheap. `entry_penalties.inputs` is snapshotted, so it's pure arithmetic. Seconds for thousands of rows.
|
||
- **Geometry change** — expensive. Crossing decisions themselves change; have to re-detect from raw positions in [[postgres-timescaledb]]. Stage-bounded. Defer to Phase 2.5 of the [[processor]].
|
||
|
||
## Per-entry timing and results
|
||
|
||
Written by the [[processor]] as cars drive (Phase 2 work — not part of Phase 1 of [[processor]]).
|
||
|
||
### `entry_segment_starts`
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `segment_id` | FK |
|
||
| `start_position` | integer — seeded position within the entry's category for this stage. Output of the strategy. |
|
||
| `target_at` | `stage.starts_at + (start_position - 1) × stage.start_interval_seconds`, plus nominal liaison durations for sub-segment starts. Operator-overridable. |
|
||
| `manual_override` | bool, default `false`. Set to `true` when a Race Marshal hand-edits `target_at` (late arrival, etc.) — flags the row as not derivable from the strategy. |
|
||
|
||
Materialized by the [[processor]] (or a Directus flow) at stage-open time, scoped per category. Until then the rows do not exist.
|
||
|
||
### `entry_crossings`
|
||
|
||
The raw timeline of detected/recorded crossings. Append-only, written by the [[processor]] as positions stream in (or by marshals via Directus for manual checkpoints).
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `geofence_id?` | one of `geofence_id` / `waypoint_id` set per row |
|
||
| `waypoint_id?` | |
|
||
| `crossed_at` | timestamp |
|
||
| `source` | enum: `gps` / `manual` |
|
||
|
||
### `entry_penalties`
|
||
|
||
Computed from `entry_crossings` + `penalty_formulas` by the [[processor]].
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `segment_id?` | FK, when applicable |
|
||
| `rule_id` | FK to `penalty_formulas` (or to a "rule set" if grouped) |
|
||
| `seconds` | computed penalty |
|
||
| `reason` | free text + structured payload |
|
||
| `formula_snapshot` | jsonb — the rule rows used at evaluation time, so the row is self-explaining if formulas later change |
|
||
| `inputs` | jsonb — peak overspeed, missed count, etc. (whatever the evaluator consumed) |
|
||
| `evaluated_at` | timestamp |
|
||
| `recomputed_at?` | set if the row has been retroactively recalculated |
|
||
| `recomputed_reason?` | what triggered the recompute |
|
||
|
||
The `formula_snapshot` + `inputs` fields make recompute a pure data transformation — no need to re-derive anything from raw GPS.
|
||
|
||
### `stage_results`
|
||
|
||
Materialized leaderboard per stage. Recomputed when the stage closes or on protest.
|
||
|
||
| Field | Notes |
|
||
|---|---|
|
||
| `entry_id` | FK |
|
||
| `stage_id` | FK |
|
||
| `raw_time` | seconds — net time from start to finish |
|
||
| `penalty_seconds` | sum of `entry_penalties` for this stage |
|
||
| `final_time` | `raw_time + penalty_seconds` |
|
||
| `position` | rank within class |
|
||
|
||
## Position flagging (cross-plane operator workflow)
|
||
|
||
Devices emit faulty data — jumpy GPS, impossible coordinates, unrealistic speeds. Track operators need to exclude such points from calculations after the fact, without deleting them (the raw record stays available for audit).
|
||
|
||
### Mechanism
|
||
|
||
The positions hypertable in [[postgres-timescaledb]] carries a `faulty boolean DEFAULT false` column. The hypertable is **exposed as a Directus collection** (read + update) so operators can flip the flag through the admin UI like any other row. Position records are owned by the [[processor]] (write side, telemetry plane), but the `faulty` flag is exclusively a business-plane operator concern.
|
||
|
||
Permission scope: operators with role `race-director` (or a more specific `track-operator` role added to the `organization_users.role` enum if granularity is needed) get update access to `positions.faulty`, scoped via the same dynamic-filter Policy pattern — only positions whose `device_id` belongs to a device entered in an event of the operator's org.
|
||
|
||
### Effect on the processor
|
||
|
||
All [[processor]] read paths against positions filter `WHERE faulty = false`:
|
||
|
||
- Peak-speed evaluation inside SLZs.
|
||
- Geofence/waypoint crossing detection.
|
||
- Replay-based recompute.
|
||
|
||
Flagged positions are still in the hypertable, still in the live broadcast (the live channel doesn't filter — operators flag historically, after the fact). They simply no longer contribute to penalties and results.
|
||
|
||
### Recompute on flag change
|
||
|
||
When `faulty` flips on a position whose timestamp is within an already-evaluated window:
|
||
|
||
1. Directus webhook fires on `positions` update where `faulty` changed.
|
||
2. Webhook enqueues a recompute request on `recompute:requests` (Redis Stream).
|
||
3. The [[processor]] identifies `entry_penalties` whose evaluation window overlaps the position's timestamp and re-evaluates them.
|
||
4. Cost is mid-band: cheaper than full geometry replay (the window is bounded), but the inputs (peak speed, crossing detection) must be re-derived from raw positions — the snapshotted `entry_penalties.inputs` no longer reflect reality once a position they were derived from is excluded.
|
||
|
||
This is a third recompute kind alongside formula recompute (cheap, snapshot arithmetic) and geometry recompute (expensive, full replay).
|
||
|
||
## Decisions made
|
||
|
||
- **Vehicle is the racing unit, not the user.** In rally the vehicle gets the time; crew is sub-relation.
|
||
- **Vehicle ownership is not modeled.** Vehicles belong to orgs (m2m); who "owns" the vehicle in the real world doesn't affect timing or tracking, so it's left out of the schema.
|
||
- **Class belongs to the entry, not the vehicle.** A vehicle could race different classes in different events; class is a per-event property.
|
||
- **Teams are org-level, durable.** A team's per-event roster is derived from `entries`, not stored separately. No `crews` collection.
|
||
- **Classes are per-event.** `classes.event_id` FK; entries reference `class_id`.
|
||
- **Discipline drives validation.** `events.discipline` decides whether vehicle is required and which crew roles/device mount points are valid.
|
||
- **Org-user role lives on the junction.** `organization_users.role` is the per-tenant role driving Directus policies.
|
||
- **Permission policies via dynamic filters.** Directus 11 Policies, one per logical role (race-director, marshal, participant, …). Each policy's filter reaches through the row → org → `organization_users` to confirm the current user has that role in *that* org. A user gets all applicable policies attached at once. No org-switcher UI needed.
|
||
- **Stage = container, segment = atomic.** Stages just order their segments. Each segment has a type (`liaison` / `special-stage` / `parc-ferme`) that drives processor and validation behavior.
|
||
- **Checkpoints are typed geofences, not their own collection.** A "checkpoint" is a geofence with `manual_verification = true` (and usually a `kind` of `ss-start` / `ss-finish`).
|
||
- **Penalty rules are data, math is code.** The `penalty_formulas` collection holds all numeric values (bracket multipliers, per-miss penalties). The [[processor]] holds one evaluator function per penalty `type`. Adding new events / new bracket tables = data only.
|
||
- **Speed limit penalties are progressive (slice-by-slice).** Each bracket contributes only to the portion of the input within its range — same as income tax. `peak_overspeed × per-bracket-rate` summed across brackets the peak crossed.
|
||
- **`retroactive` defaults differ by what's edited.** Formulas default `true` (math fixes apply across the field). Geometry defaults `false` (physical crossings stand on their own). Per-edit override at save time.
|
||
- **`entry_penalties` snapshot inputs and rule rows.** Recompute is pure arithmetic from snapshots, not re-derivation from raw GPS — the rare exception being geometry retroactive changes.
|
||
- **Positions carry a `faulty` flag.** Operator-controlled, default `false`, set after the fact through Directus when a GPS reading is unrealistic. [[processor]] filters `WHERE faulty = false` on every read; flagging a position triggers a windowed recompute of affected penalties. The hypertable is exposed as a Directus collection for this workflow.
|
||
- **Start order is per-stage, declarative, strategy-driven.** Stages carry `start_order_strategy` + params, materialized into `entry_segment_starts` at stage-open time, scoped per category. Penalties never feed the next stage's grid; only clean SS time does. Operator overrides via `manual_override` for late arrivals.
|
||
- **Stages have a `role`.** `prologue` / `regular` / `epilogue`. Drives default strategy choice and lets the standings logic exclude the prologue from overall-time totals when a federation requires that. Most rallies = one prologue + N regulars; some end with an epilogue seeded by inverse-of-overall.
|
||
- **CP missing vs CP-late-past-closing are distinct event types** with the same penalty formula. Rally Albania §12.4 (missing) and §12.6 (arrived after closing hour) both pay "worst valid + 120 min" but trigger on different processor signals — one from "no crossing detected within stage window," one from "crossing detected but after `time_control_closed_at`." Both surface as `entry_penalties` rows with distinct `type`s sharing a formula row.
|
||
- **Tiebreaker is reverse-stage order.** Rally Albania §6.19: same total → best last-stage time wins; if still tied, walk back stage-by-stage. Pure SQL on `stage_results`; no schema impact.
|
||
- **Crews are per-entry, not reusable.** No `crews` collection. Each entry builds its `entry_crew` rows fresh; the SPA solves "register the usual Toyota crew" via a "copy crew from previous entry" action that clones rows. Saved crews would go stale fast (people swap roles, drop out); copy-from-previous covers the real case without that risk.
|
||
- **Non-bracket federation rules ship as code (Option A), one PR per rule shape.** When a federation publishes math that doesn't fit the bracket / flat-per-miss model (curves, multi-input aggregates, conditional rules), the response is to add a new evaluator function to the registry plus the fields it needs on `penalty_formulas`. Explicitly **not** building an in-database expression engine (Option B). Reason: expression engines bring sandboxing, validation, authoring UX, testing, versioning, and determinism obligations that dwarf the per-rule PR cost. PR-per-rule keeps the math reviewable, deterministic, testable, and versioned through normal git history. Stability over flexibility.
|
||
|
||
## Open questions
|
||
|
||
- **Geometry retroactivity engine** — the position-replay path for geofence/SLZ geometry edits is non-trivial. Defer to Phase 2.5 of [[processor]] once Phase 2 is shipping.
|
||
|
||
## Relationship to existing wiki
|
||
|
||
This schema lives in [[directus]] (the business plane). The [[processor]] writes telemetry data (positions hypertable) keyed by `device_id`; the join from telemetry back to "who was racing" is `device_id` → `entry_devices` → `entry` → everything else. See [[plane-separation]] for why the telemetry plane stays ignorant of this schema.
|