Operations, functions & permissions
Operations
Registered bindings, each one a permission check plus a call into the in-scope function below.
| Operation | Permission | Does |
|---|---|---|
absence/configure-leave-type | absence:configure | register/update a leave type's key, floor, active flag |
absence/list-leave-types | absence:read | list registered types |
absence/record-entry | absence:configure | append an accrual / correction / carryover entry |
absence/request | absence:request (per subject) | file a request for a subject |
absence/decide | absence:approve | approve (books, floor-checked) or reject |
absence/cancel | absence:approve, or absence:request (per subject) for one's own still-requested row | withdraw or cancel; an approved cancel writes the reversal |
absence/expire-stale | absence:approve | cancel requests past their start date — the declared schedule's target |
absence/balance | absence:read (per subject) | balance-as-of fold |
absence/availability | absence:read (per subject) | per-date coverage over a range |
absence/list-requests | absence:read (per subject when filtered, node otherwise) | requests, by subject and/or status |
absence/list-entries | absence: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.
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 bindingEvery write takes a subject:
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.ts — leaveType, 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.cancelAbsenceon anapprovedrequest writes the compensatingreversalin the same transaction as the status change; on arequestedrow it touches no ledger.entriesInWindowhas 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.availabilityclamps 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:requestis 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:approvecovers 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:configurecovers leave-type policy and direct ledger writes (recordEntry) — the administrator's escape hatch, deliberately floor-unchecked.absence:readunnarrowed is the planner's and manager's view; narrowed, it is "my balance".