Skip to content

Operations, functions & permissions

Operations

Registered bindings, each one a permission check plus a call into the in-scope function below.

OperationPermissionDoes
absence/configure-leave-typeabsence:configureregister/update a leave type's key, floor, active flag
absence/list-leave-typesabsence:readlist registered types
absence/record-entryabsence:configureappend an accrual / correction / carryover entry
absence/requestabsence:request (per subject)file a request for a subject
absence/decideabsence:approveapprove (books, floor-checked) or reject
absence/cancelabsence:approve, or absence:request (per subject) for one's own still-requested rowwithdraw or cancel; an approved cancel writes the reversal
absence/expire-staleabsence:approvecancel requests past their start date — the declared schedule's target
absence/balanceabsence:read (per subject)balance-as-of fold
absence/availabilityabsence:read (per subject)per-date coverage over a range
absence/list-requestsabsence:read (per subject when filtered, node otherwise)requests, by subject and/or status
absence/list-entriesabsence:read (per subject)the raw ledger for a subject

Checks marked (per subject) pass the subject's EntityRef, so a principal holding an entity-narrowed grant on their own ref — an employee, a fältarbetare with a login — reaches their own balance, requests, and withdrawals while holding no role at all. Node holders (managers, planners, HR) pass the same checks unnarrowed.

In-scope functions

The composable surface. A vertical calls these inside its own operation and its own permission check, in one transaction — this is how you extend the engine without forking it.

ts
configureLeaveType(ctx, { key, floor?, active? })                → LeaveType
listLeaveTypes(ctx)                                              → LeaveType[]
recordEntry(ctx, { subject, leaveTypeKey, entryKind,             // accrual|correction|carryover
                   delta, effectiveDate, note? })                → AbsenceEntry
requestAbsence(ctx, { subject, leaveTypeKey,
                      startDate, endDate, days, note? })         → AbsenceRequest
decideAbsence(ctx, { requestId, decision, note? })               → { request, booking: AbsenceEntry | null }
cancelAbsence(ctx, { requestId, reason? })                       → { request, reversal: AbsenceEntry | null }
expireStaleRequests(ctx)                                         → { expired: number }
balanceAsOf(ctx, { subject, leaveTypeKey, asOf? })               → string          // pure fold
availability(ctx, { subject, from, to })                         → { days, requests }
listRequests(ctx, { subject?, status? })                         → AbsenceRequest[]
listEntries(ctx, { subject, leaveTypeKey? })                     → AbsenceEntry[]
entriesInWindow(ctx, { from, to, entryKind? })                   → AbsenceEntry[]  // in-scope only, no binding

Every write takes a subject:

ts
subject: {
  ref: EntityRef,               // your noun — { entityType: 'employee' | 'resource' | …, entityId }
  dataSubjectId: DataSubjectId, // keys erasure on every event this write emits
}

Reads take the bare ref — no erasure key is needed to look.

What they return is parsed, not asserted. Every value crossing back out of this engine goes through the schema the engine publishes in src/schemas.tsleaveType, absenceEntry, absenceRequest, absenceDay — the same schema the matching operation points its output at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents loud. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become null and render it — now the read throws at the seam. The reads name their columns for the same reason: the SELECT list is derived from the row schema (columnsOf), where SELECT * would publish whatever the physical table happens to hold.

The fold is parsed too, and here that is the point. A balance is the sum of every delta in the ledger, so a delta that drifted is the one value that would otherwise cross as a number nobody questions — wrong on a screen, never a throw. balanceAsOf parses each delta as it folds and the answer as a signed decimal, and availability parses the day series it computes. engines/absence/src/seam.ts is one line, engineSeam('engine-absence'); the helpers themselves live in @substrat-run/contracts.

Notes worth knowing:

  • decideAbsence(approve) re-folds at decision time and enforces the leave type's floor then — the request may be days old and the world may have moved.
  • cancelAbsence on an approved request writes the compensating reversal in the same transaction as the status change; on a requested row it touches no ledger.
  • entriesInWindow has no operation binding — it exists for vertical compositions (a payroll export collecting the period's bookings; a planner sweeping a route window) that gate it behind their own permission.
  • availability clamps to the queried range and reports approved coverage only — a merely-requested absence never shows as covered.

Permissions

absence:read · absence:request · absence:approve · absence:configure

  • absence:request is the self-service key: granted entity-narrowed on a subject's own ref, it lets that person file — and withdraw — their own requests, and nothing else.
  • absence:approve covers deciding, cancelling an approved absence, and the expiry sweep — it is also the single permission the declared schedule's system principal is granted, which is exactly what appears in the permission diff.
  • absence:configure covers leave-type policy and direct ledger writes (recordEntry) — the administrator's escape hatch, deliberately floor-unchecked.
  • absence:read unnarrowed is the planner's and manager's view; narrowed, it is "my balance".

The hard parts, hosted.