Skip to content

Meridian (HR)

demos/meridian — a multi-country HR vertical: leave and absence, project time reporting, expenses, and onboarding, in one role-adaptive app that is an employee's self-service tool and a manager's console at once.

Overview

Meridian is the deliberate shape-breaker. Every other demo composes a domain engine; Meridian has none to compose — there is no absence engine, no time-tracking engine, no expenses engine. So leave/absence, time reporting, and expenses are all vertical code, and that is the point: it proves the kernel's guarantees — nested tenancy, permissions, audit, GDPR — hold with zero engine support, and that Substrat's value isn't quietly borrowed from the work-order state machine.

It's interesting for three reasons the field-service demos can't show:

  • A domain with no engine. The kernel carries HR alone. The only engine it touches is protocol, reused a third time for onboarding checklists.
  • Two countries from one codebase. Sweden (25 days + saved days, VAB) and Spain (22 days, registro de jornada) are two scopes under one tenant, diverging by data and per-scope config, not a fork.
  • One app, two audiences. An employee sees only their own record; a team lead who is also an employee gets a Manage section beside their own My work — the same app, the same permission checks, adapting to who signs in.

And it surfaces the platform's next engine: the absence / entry-ledger candidate (engines) — because its absence ledger and its time ledger are the same append-only shape the work-order engine already owns.

At a glance

Packagedemos/meridian (private)
Tenancy shape2 tenants — Nordljus AB (Sweden + Spain scopes) · Solmark AB (the cross-tenant attack victim)
Engines composedprotocol — onboarding checklists, bound to an employee ref
Own tableshr_employees · hr_leave_types · hr_absence_ledger (append-only) · hr_leave_requests · hr_projects · hr_time_entries (append-only) · hr_expenses · hr_holidays
Roleshr-admin (tenant) · manager / payroll (scope) — employees are entity-narrowed grants, not a role
Permission surfacePERMISSIONS.md — 18 keys, 3 roles
Appone role-adaptive React app (employee mobile + manager web), fjord-blue design system, light + dark
StatusWorking — 9-test scenario green on the pure-SQLite adapter

Engines composed

Only one, and it's the reuse story: protocol for onboarding. hr/start-onboarding calls the engine's instantiateProtocol(ctx, …) bound to an employee EntityRef, and the vertical declares the protocol → employee relation so the permission walk reaches the employee's own-record grant. A nice twist: onboarding is signed by the employee themselves (their own acknowledgement), not a supervisor — the same engine as Callout, where the arbetsledare signs; the vertical's grant decides who holds protocol:sign.

Everything else is vertical code — and the most interesting part is what isn't an engine yet. Two ledgers, one shape:

  • hr_absence_ledger — vacation/absence, where an entry moves a balance (accrual +, booking , correction, carryover). Balance is a fold; the current value is never stored.
  • hr_time_entries — worked hours booked to a project ref, where an entry moves no balance.

Both are append-only (a correction is a new row), both fold to a value, both emit an event per write — the identical discipline the work-order engine already owns for order-bound time. That three unrelated capabilities want the same invariant is the signal that an entry-ledger engine should be extracted once a second HR-shaped consumer forces it. Its generic core may even belong in the kernel (a ledger-entries attachment contract); the domain semantics — accrual, the approval state machine, the no-negative-beyond-policy floor — stay in the engine. Until then it is honest vertical code with the invariants written cleanly, so the extraction is later mechanical.

The variable-pay payroll export is the Callout invoice basis pattern re-cast: approved absence + expenses leave as a file for a payroll provider. Payroll itself is a deep-domain moat — integrated, never rebuilt.

The cast & what's denied

Six principals span the whole matrix — pure employee, dual-role, pure manager, and an attacker:

PersonaHoldsSees
Elin (SE) · Pablo (ES)entity-narrowed grant on their own employee recordMy work only — their own balance, timesheet, expenses, onboarding
Mats — team leadmanager role and his own employee recordMy work + Manage — the dual-role case
Petrapayroll rolethe variable-pay export
Heddahr-admin role (tenant-level)Manage across both country scopes
Malloryhr-admin in Solmark ABnothing of Nordljus — the attacker

The denials the scenario asserts — because denials are the demo:

  • an employee cannot approve their own leave, read a colleague's balance, or enumerate the team (hr/roster is gated on node-level absence:read, which employees hold only per-entity);
  • a manager sees their department but never compensation — the roster read carries no national_id or salary;
  • cross-tenant: Mallory operating from Solmark's scope reaches none of Nordljus's data — the isolation holds structurally, not by a filter someone remembered to add.

The full surface — every key, which role holds it, and the entity-narrowed grant shapes — is the checked-in PERMISSIONS.md, re-emitted by CI so it can't drift from what runs.

The app

One React app, composed over the vertical's single API, that adapts to the principal and the viewport. The nav is gated on what you are — an employee record grants My work, manager/HR permissions grant Manage — and the layout switches with the screen:

  • Employee (mobile-first) — a vacation-balance hero, and four one-handed, low-typing flows: request time off (with live balance impact), log time (append-only, hours stepper), submit expense (camera-first), and e-sign onboarding.
  • Manager (desktop-led) — an approvals Inbox (decline always takes a reason), a team absence calendar, timesheet review, onboarding tracking, and a salary-free team roster.
  • Design system — the fjord-blue "Meridian" palette, a colourblind-safe leave-type palette (every colour paired with a shape), light and dark. The employee and manager surfaces read as one system because they are one app.

Run it

sh
pnpm --filter @substrat-run/demo-meridian dev

Starts the API on :8875 and the app on :5275. Open it, resize the window to watch the shell move between bottom-tabs and sidebar, and switch personas (Elin → Mats → Hedda) to watch the sections adapt.

The executable spec is the scenario test — nine cases replayed headlessly on the pure-SQLite adapter:

sh
pnpm --filter @substrat-run/demo-meridian test

It asserts the ledger fold (request → approve drops the balance 25 → 22), the approval state machine (can't skip, can't re-decide), the no-negative floor, the self-service and cross-tenant denials, onboarding reuse, and Sweden/Spain diverging from the same code. The PERMISSIONS.md and a single append-only 0001-init migration are the two human checkpoints the platform makes unskippable.

The hard parts, hosted.