Research & Education
ADL v0.3 for Research Administration
A HERAS grants-management ADL domain implementing 2 CFR §200 uniform-guidance controls, built to the regulation and the ADL v0.3 specification.
Research administration turns on the fine print of federal award terms: the 2 CFR §200 uniform-guidance thresholds on budget transfers, the period-of-performance boundaries on every expenditure, effort commitments that cannot exceed 100% across awards. This paper works a research-administration grants domain (HERAS) in the Application Definition Language (ADL) as a complete, runnable definition, with the §200 controls computed against the total approved award budget the regulation actually specifies.
The domain below shows how those award-level rules become prevention rather than after-the-fact detection, declared in ADL and enforced by the Kalos runtime at the moment a transaction would breach them.
422 RULE_VIOLATION before it can commit, and the rules log becomes the audit evidence.Domain: grants
One domain, five resources. The cross-resource compliance rules (§200.308 spans Award + Transfer; §200.430 spans Commitments across awards) require the resources to share a domain. The rules engine and relationships operate within a domain, and nothing in the v0.3 spec defines a cross-domain foreign key.
domain:
name: grants
version: "1.0.0"
description: >
Sponsored-programs administration: awards, budget categories, budget
transfers, expenditures, and effort commitments. The 2 CFR 200.308 transfer
threshold and the 200.430 effort cap are enforced by reject rules BEFORE
the write commits — prevention, with the rules log as the audit evidence.
channels:
email:
provider: smtp
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
from: "spo-noreply@example.edu"
# FERPA: student workers appear as grant personnel.
privacy:
enabled: true
classifications:
personalData: { description: "Personnel and student-worker identifiers (FERPA)", retention: 7 years }
subjectIdentifier: { primary: personnelId }
anonymization:
method: pseudonymize
rules:
default: "Redacted"
personnelName: "$pseudonym"
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: update }
enums:
AwardStatus:
values:
pending: { label: "Pending", color: "#94a3b8" }
active: { label: "Active", color: "#10b981" }
nce_pending: { label: "NCE Pending", color: "#f59e0b" }
suspended: { label: "Suspended", color: "#ef4444" }
in_closeout: { label: "In Closeout", color: "#6366f1" }
closed: { label: "Closed", color: "#059669", final: true }
terminated: { label: "Terminated", color: "#7f1d1d", final: true }
AwardType:
values: [grant, cooperative_agreement, contract, subaward_in]
CategoryType:
values:
senior_personnel: { label: "Senior Personnel" }
other_personnel: { label: "Other Personnel" }
fringe_benefits: { label: "Fringe Benefits" }
equipment: { label: "Equipment" }
travel: { label: "Travel" }
participant_support: { label: "Participant Support" }
other_direct: { label: "Other Direct Costs" }
indirect: { label: "Indirect Costs (F&A)" }
TransferStatus:
values:
requested: { label: "Requested", color: "#3b82f6" }
under_review: { label: "Under Review", color: "#8b5cf6" }
approved: { label: "Approved", color: "#10b981" }
completed: { label: "Completed", color: "#059669", final: true }
rejected: { label: "Rejected", color: "#ef4444", final: true }
ExpenditureStatus:
values:
pending: { label: "Pending", color: "#94a3b8" }
under_review: { label: "Under Review", color: "#8b5cf6" }
approved: { label: "Approved", color: "#10b981" }
flagged: { label: "Flagged", color: "#f59e0b" }
processed: { label: "Processed", color: "#059669", final: true }
rejected: { label: "Rejected", color: "#ef4444" }
disallowed: { label: "Disallowed", color: "#7f1d1d", final: true }
CostType:
values: [salary, fringe, supplies, equipment, travel, participant, subaward, tuition, other]
PersonnelRole:
values: [pi, co_pi, postdoc, grad_student, undergrad, staff, other]
EffortStatus:
values:
committed: { label: "Committed", color: "#3b82f6" }
adjusted: { label: "Adjusted", color: "#f59e0b" }
certified: { label: "Certified", color: "#10b981", final: true }
# ── Domain-level compliance policy: prevention, not post-hoc detection ──────
rules:
federalTransferThreshold:
description: >
2 CFR 200.308: cumulative budget transfers on an award may not exceed
10% of the TOTAL approved budget or $25,000, whichever is less, without
sponsor prior approval. Blocks completion of a breaching transfer that
carries no sponsor approval reference. Basis is the AWARD total — not
the category budget.
trigger:
resource: Transfer
on: [update]
fields: [status]
condition: "$record.status == 'completed' && $record.sponsorApprovalRef == null"
condition: >
sum(Transfer, 'amount', awardId == $record.awardId && status == 'completed' && id != $record.id)
+ $record.amount
> if(findOne(Award, id == $record.awardId).totalAmount * 0.10 < 25000,
findOne(Award, id == $record.awardId).totalAmount * 0.10,
25000)
action: reject
severity: error
message: "Cumulative transfers on award {awardNumber} would exceed the federal threshold ({threshold}) — sponsor prior approval required"
messageData:
awardNumber: "findOne(Award, id == $record.awardId).awardNumber"
threshold: >
if(findOne(Award, id == $record.awardId).totalAmount * 0.10 < 25000,
findOne(Award, id == $record.awardId).totalAmount * 0.10, 25000)
regulation: "2 CFR §200.308"
priority: 100
enabled: true
transferThresholdWithApproval:
description: >
Evidence trail: a breaching transfer that DOES carry sponsor approval is
allowed but logged — the rules log entry is the auditor's answer to
"where is the sponsor approval?"
trigger:
resource: Transfer
on: [update]
fields: [status]
condition: "$record.status == 'completed' && $record.sponsorApprovalRef != null"
condition: >
sum(Transfer, 'amount', awardId == $record.awardId && status == 'completed' && id != $record.id)
+ $record.amount
> if(findOne(Award, id == $record.awardId).totalAmount * 0.10 < 25000,
findOne(Award, id == $record.awardId).totalAmount * 0.10,
25000)
action: log
severity: info
message: "Threshold exceeded with sponsor approval on file ({sponsorApprovalRef})"
messageData:
sponsorApprovalRef: "$record.sponsorApprovalRef"
regulation: "2 CFR §200.308"
priority: 110
enabled: true
effortOvercommitment:
description: >
2 CFR 200.430: a person's committed effort across ALL awards cannot
exceed 100%. This is the check every SPO performs by spreadsheet today.
trigger:
resource: Commitment
on: [create, update]
fields: [committedPercent]
condition: >
sum(Commitment, 'committedPercent',
personnelId == $record.personnelId && id != $record.id && status != 'certified')
+ $record.committedPercent > 100
action: reject
severity: error
message: "Total committed effort for {personnelName} would exceed 100%"
messageData:
personnelName: "$record.personnelName"
regulation: "2 CFR §200.430"
priority: 100
enabled: true
# ── Time-based obligations live here, not in fieldChange events ─────────────
schedules:
awardEnding:
description: "Closeout early warning at 90/60/30 days before award end"
type: relative
source:
resource: Award
filter: "status == 'active'"
relativeTo: endDate
tiers:
- daysUntil: 90
action: notify
tag: 90day
channels: [email]
recipients: { roles: [spoOfficer] }
template:
subject: "Award {awardNumber} ends in 90 days"
body: "'{title}' ({sponsor}) ends {endDate}. Begin closeout planning or NCE discussion."
- daysUntil: 60
action: notify
tag: 60day
channels: [email]
recipients: { roles: [spoOfficer] }
template:
subject: "Award {awardNumber} ends in 60 days"
body: "'{title}' ends {endDate}."
- daysUntil: 30
action: notify
tag: 30day
channels: [email]
recipients: { roles: [spoOfficer, grantAccountant] }
template:
subject: "Award {awardNumber} ends in 30 days — final expenditure review"
body: "'{title}' ends {endDate}. Outstanding encumbrances must clear before the FFR."
resources:
# ─────────────────────────────────────────────────────────────────────────
Award:
object:
id: { type: uuid, primaryKey: true }
awardNumber: { type: string, required: true, unique: true, immutable: true, maxLength: 40 }
title: { type: string, required: true, maxLength: 255 }
sponsor: { type: string, required: true, maxLength: 120 }
sponsorAwardId: { type: string, maxLength: 60 }
piId: { type: uuid, required: true }
piName: { type: string, required: true, maxLength: 120 }
department: { type: string, required: true, maxLength: 120 }
status: { $ref: '#/enums/AwardStatus', default: pending }
awardType: { $ref: '#/enums/AwardType' }
totalAmount: { type: money, required: true, min: 0 }
startDate: { type: date, required: true }
endDate: { type: date, required: true }
cfda: { type: string, maxLength: 20 }
fain: { type: string, maxLength: 40 }
indirectCostRate: { type: percentage }
costShareRequired: { type: boolean, default: false }
costShareAmount: { type: money }
accountNumber: { type: string, maxLength: 40 }
proposalId: { type: uuid }
nceJustification: { type: text }
finalFFRDate: { type: date }
activatedAt: { type: datetime }
closedAt: { type: datetime }
notes: { type: text }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
categories: { type: hasMany, target: Category, foreignKey: awardId }
transfers: { type: hasMany, target: Transfer, foreignKey: awardId }
expenditures: { type: hasMany, target: Expenditure, foreignKey: awardId }
commitments: { type: hasMany, target: Commitment, foreignKey: awardId }
indexes:
- { fields: [status] }
- { fields: [piId] }
- { fields: [department] }
- { fields: [endDate] }
stateMachine:
field: status
initial: pending
states:
pending:
transitions:
activate:
to: active
guards: [spoOfficer, admin]
requiredFields: [accountNumber]
sets:
activatedAt: "now()"
active:
transitions:
suspend:
to: suspended
guards: [spoOfficer, admin]
closeout:
to: in_closeout
guards: [spoOfficer, admin]
requestNCE:
to: nce_pending
guards: [owner, spoOfficer, admin]
requiredFields: [nceJustification]
nce_pending:
transitions:
approveNCE:
to: active
guards: [spoOfficer, admin]
# The approver supplies the extended endDate in the transition
# body; extra fields are applied with the transition, so the new
# date lands on endDate directly and the extension takes effect.
denyNCE:
to: active
guards: [spoOfficer, admin]
suspended:
transitions:
reactivate:
to: active
guards: [spoOfficer, admin]
terminate:
to: terminated
guards: [admin]
in_closeout:
transitions:
complete:
to: closed
guards: [spoOfficer, admin]
requiredFields: [finalFFRDate]
sets:
closedAt: "now()"
closed:
final: true
terminated:
final: true
validation:
periodSane:
rule: "endDate == null || startDate == null || endDate > startDate"
message: "Award end date must be after the start date"
costShareNeedsAmount:
rule: "costShareRequired == false || costShareAmount != null"
message: "Cost sharing is required but no committed amount is recorded"
features:
audit: true
search: true
audit:
exclude: [updatedAt]
history:
events:
created:
on: create
capture: [awardNumber, title, sponsor, piName, totalAmount, startDate, endDate]
label: "Award {awardNumber} created"
detail: "'{title}' — {sponsor}, {totalAmount}, {startDate} to {endDate}"
icon: lucide:file-plus
severity: info
activated:
on: transition
transition: activate
capture: [accountNumber, awardNumber]
label: "Award activated — Account {accountNumber}"
icon: lucide:check-circle
severity: success
nceRequested:
on: transition
transition: requestNCE
capture: [nceJustification, endDate]
label: "No-cost extension requested"
detail: "Current end date: {endDate}. Justification: {nceJustification}"
icon: lucide:calendar-plus
severity: warning
nceApproved:
on: transition
transition: approveNCE
capture: [endDate]
label: "No-cost extension approved — new end date {endDate}"
icon: lucide:check
severity: success
nceDenied:
on: transition
transition: denyNCE
label: "No-cost extension denied"
icon: lucide:x
severity: warning
suspended:
on: transition
transition: suspend
label: "Award suspended"
icon: lucide:pause-circle
severity: error
reactivated:
on: transition
transition: reactivate
label: "Award reactivated"
icon: lucide:play-circle
severity: success
closeoutStarted:
on: transition
transition: closeout
label: "Closeout initiated"
icon: lucide:archive
severity: info
closed:
on: transition
transition: complete
capture: [finalFFRDate]
label: "Award closed — Final FFR submitted {finalFFRDate}"
icon: lucide:check-circle-2
severity: success
piChanged:
on: fieldChange
field: piId
capture: [piName]
label: "PI changed to {piName}"
icon: lucide:user-check
severity: warning
permissions:
list: [authenticated]
get: [authenticated]
create: [spoOfficer, admin]
update: [spoOfficer, admin]
delete: [admin]
fields:
accountNumber: { write: [spoOfficer, admin] }
totalAmount: { write: [spoOfficer, admin] }
indirectCostRate: { write: [spoOfficer, admin] }
# ─────────────────────────────────────────────────────────────────────────
Category:
mode: sheet
description: >
A budget line within an award. Encumbered and expended amounts are
AGGREGATIONS over expenditures — not hand-entered numbers — so the
available balance is true by construction, not by reconciliation.
No per-category "federal threshold": 2 CFR 200.308 is award-level and
enforced by the domain rule.
object:
id: { type: uuid, primaryKey: true }
awardId: { type: uuid, required: true, immutable: true }
category: { $ref: '#/enums/CategoryType', required: true }
fiscalYear: { type: integer, required: true, min: 2000, max: 2100 }
budgetedAmount: { type: money, required: true, min: 0 }
notes: { type: text }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
award: { type: belongsTo, target: Award, foreignKey: awardId, onDelete: restrict }
expenditures: { type: hasMany, target: Expenditure, foreignKey: categoryId }
indexes:
- { fields: [awardId] }
- { fields: [category] }
- { fields: [fiscalYear] }
computed:
encumberedAmount:
type: money
formula: "sum(Expenditure, 'amount', categoryId == $this.id && status == 'approved')"
stored: true
reactive: true
recomputeOn: [Expenditure.create, Expenditure.update, Expenditure.delete]
expendedAmount:
type: money
formula: "sum(Expenditure, 'amount', categoryId == $this.id && status == 'processed')"
stored: true
reactive: true
recomputeOn: [Expenditure.create, Expenditure.update, Expenditure.delete]
transfersIn:
type: money
formula: "sum(Transfer, 'amount', toCategoryId == $this.id && status == 'completed')"
stored: true
reactive: true
recomputeOn: [Transfer.create, Transfer.update, Transfer.delete]
transfersOut:
type: money
formula: "sum(Transfer, 'amount', fromCategoryId == $this.id && status == 'completed')"
stored: true
reactive: true
recomputeOn: [Transfer.create, Transfer.update, Transfer.delete]
availableBalance:
type: money
formula: "budgetedAmount + transfersIn - transfersOut - encumberedAmount - expendedAmount"
stored: true
updateOn: [budgetedAmount, transfersIn, transfersOut, encumberedAmount, expendedAmount]
dynamicFields:
enabled: true
allowTypes: [money, string, text, date]
maxColumns: 20
permissions:
addColumn: [spoOfficer, admin]
deleteColumn: [admin]
features:
audit: true
history:
events:
allocated:
on: create
capture: [category, budgetedAmount, fiscalYear]
label: "{category} — {budgetedAmount} allocated for FY{fiscalYear}"
icon: lucide:wallet
severity: info
budgetRevised:
on: fieldChange
field: budgetedAmount
capture: [category, budgetedAmount]
label: "{category} budget revised to {budgetedAmount}"
icon: lucide:edit
severity: warning
balanceWarning:
on: fieldChange
field: availableBalance
condition: "availableBalance < budgetedAmount * 0.10 && availableBalance > 0"
capture: [category, availableBalance, budgetedAmount]
label: "{category} — less than 10% balance remaining"
detail: "Available: {availableBalance} of {budgetedAmount}"
icon: lucide:alert-triangle
severity: warning
overExpended:
on: fieldChange
field: availableBalance
condition: "availableBalance < 0"
capture: [category, availableBalance, expendedAmount, budgetedAmount]
label: "OVER-EXPENDITURE — {category}"
detail: "Expended {expendedAmount} against {budgetedAmount}. Over by {availableBalance}"
icon: lucide:alert-octagon
severity: error
permissions:
list: [authenticated]
get: [authenticated]
create: [spoOfficer, admin]
update: [spoOfficer, grantAccountant, admin]
delete: [admin]
fields:
budgetedAmount: { write: [spoOfficer, admin] }
# ─────────────────────────────────────────────────────────────────────────
Transfer:
description: >
A budget transfer between two categories of one award. Two-sided by
construction (from/to are FKs), atomic
(one record), approval-linked (sponsorApprovalRef), and threshold-gated
by the domain rule BEFORE completion commits.
object:
id: { type: uuid, primaryKey: true }
awardId: { type: uuid, required: true, immutable: true }
fromCategoryId: { type: uuid, required: true, immutable: true }
toCategoryId: { type: uuid, required: true, immutable: true }
amount: { type: money, required: true, min: 0.01 }
justification: { type: text, required: true }
status: { $ref: '#/enums/TransferStatus', default: requested }
sponsorApprovalRef: { type: string, maxLength: 80 }
sponsorApprovalDate: { type: date }
createdById: { type: string, required: true, immutable: true, maxLength: 100 }
approvedById: { type: string, maxLength: 100 }
rejectionReason: { type: text }
completedAt: { type: datetime }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
award: { type: belongsTo, target: Award, foreignKey: awardId, onDelete: restrict }
fromCategory: { type: belongsTo, target: Category, foreignKey: fromCategoryId, onDelete: restrict }
toCategory: { type: belongsTo, target: Category, foreignKey: toCategoryId, onDelete: restrict }
indexes:
- { fields: [awardId] }
- { fields: [fromCategoryId] }
- { fields: [toCategoryId] }
- { fields: [status] }
stateMachine:
field: status
initial: requested
states:
requested:
transitions:
review:
to: under_review
guards: [grantAccountant, spoOfficer, admin]
under_review:
transitions:
approve:
to: approved
# SoD: the approving SPO officer is not the requester.
guard: "hasRole('spoOfficer') && field('createdById') != userId()"
sets:
approvedById: "$user.id"
reject:
to: rejected
guards: [spoOfficer, admin]
requiredFields: [rejectionReason]
approved:
transitions:
complete:
to: completed
# The federalTransferThreshold rule evaluates here, before the
# write commits. A breaching transfer without sponsorApprovalRef
# is rejected at 422 RULE_VIOLATION — prevention, not detection.
guards: [grantAccountant, admin]
sets:
completedAt: "now()"
completed:
final: true
rejected:
final: true
validation:
distinctCategories:
rule: "fromCategoryId != toCategoryId"
message: "A transfer must move funds between two different categories"
sameAward:
rule: >
query('Category', { id: fromCategoryId })[0].awardId == awardId
&& query('Category', { id: toCategoryId })[0].awardId == awardId
message: "Both categories must belong to the transfer's award"
approvalRefNeedsDate:
rule: "sponsorApprovalRef == null || sponsorApprovalDate != null"
message: "A sponsor approval reference must carry the approval date"
features:
audit: true
history:
events:
requested:
on: create
capture: [amount, justification]
label: "Transfer requested — {amount}"
detail: "{justification}"
icon: lucide:arrow-right-left
severity: info
transferApproved:
on: transition
transition: approve
capture: [amount, sponsorApprovalRef, sponsorApprovalDate]
label: "Transfer approved"
detail: "Sponsor approval: {sponsorApprovalRef} ({sponsorApprovalDate})"
icon: lucide:check
severity: success
transferCompleted:
on: transition
transition: complete
capture: [amount, completedAt]
label: "Transfer completed — {amount}"
icon: lucide:check-circle
severity: success
transferRejected:
on: transition
transition: reject
capture: [rejectionReason]
label: "Transfer rejected"
detail: "{rejectionReason}"
icon: lucide:x-circle
severity: error
permissions:
list: [authenticated]
get: [authenticated]
create: [grantAccountant, spoOfficer, admin]
update: [grantAccountant, spoOfficer, admin]
delete: [admin]
fields:
sponsorApprovalRef: { write: [spoOfficer, admin] }
sponsorApprovalDate: { write: [spoOfficer, admin] }
approvedById: { write: [admin] } # normally set only via the approve transition
# ─────────────────────────────────────────────────────────────────────────
Expenditure:
object:
id: { type: uuid, primaryKey: true }
awardId: { type: uuid, required: true, immutable: true }
categoryId: { type: uuid, required: true, immutable: true }
transactionDate: { type: date, required: true }
amount: { type: money, required: true, min: 0.01 }
vendor: { type: string, maxLength: 120 }
description: { type: text, required: true }
costType: { $ref: '#/enums/CostType' }
status: { $ref: '#/enums/ExpenditureStatus', default: pending }
submittedById: { type: string, required: true, immutable: true, maxLength: 100 }
submittedByName: { type: string, required: true, maxLength: 120 }
purchaseOrderNum: { type: string, maxLength: 40 }
invoiceNum: { type: string, maxLength: 40 }
allowable: { type: boolean }
allocable: { type: boolean }
reasonable: { type: boolean }
flagReason: { type: text }
rejectionReason: { type: text }
disallowReason: { type: text }
processedAt: { type: datetime }
notes: { type: text }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
award: { type: belongsTo, target: Award, foreignKey: awardId, onDelete: restrict }
category: { type: belongsTo, target: Category, foreignKey: categoryId, onDelete: restrict }
indexes:
- { fields: [awardId] }
- { fields: [categoryId] }
- { fields: [status] }
- { fields: [transactionDate] }
stateMachine:
field: status
initial: pending
states:
pending:
transitions:
review:
to: under_review
guards: [grantAccountant, admin]
under_review:
transitions:
approve:
to: approved
guards: [spoOfficer, admin]
requiredFields: [allowable, allocable, reasonable]
flag:
to: flagged
guards: [grantAccountant, spoOfficer, admin]
requiredFields: [flagReason]
reject:
to: rejected
guards: [spoOfficer, admin]
requiredFields: [rejectionReason]
flagged:
transitions:
resolve:
to: under_review
guards: [grantAccountant, spoOfficer, admin]
reject:
to: rejected
guards: [spoOfficer, admin]
requiredFields: [rejectionReason]
approved:
transitions:
process:
to: processed
guards: [grantAccountant, admin]
sets:
processedAt: "now()"
disallow:
to: disallowed
guards: [spoOfficer, admin]
requiredFields: [disallowReason]
processed:
final: true
rejected:
transitions:
resubmit:
to: pending
guards: [owner, admin]
disallowed:
final: true
validation:
# Period of performance — one of the twelve single-audit areas the
# paper listed and then never checked.
withinPeriod:
rule: >
transactionDate >= query('Award', { id: awardId })[0].startDate
&& transactionDate <= query('Award', { id: awardId })[0].endDate
message: "Transaction date is outside the award's period of performance (2 CFR Subpart F audit area)"
categoryBelongsToAward:
rule: "query('Category', { id: categoryId })[0].awardId == awardId"
message: "The budget category must belong to the expenditure's award"
costTestConsistent:
rule: "status != 'approved' || (allowable == true && allocable == true && reasonable == true)"
message: "An approved expenditure must pass all three cost tests (allowable, allocable, reasonable)"
features:
audit: true
history:
events:
submitted:
on: create
capture: [amount, vendor, description, costType, submittedByName]
label: "Expenditure submitted — {amount} to {vendor}"
detail: "{description}. Type: {costType}"
icon: lucide:receipt
severity: info
approved:
on: transition
transition: approve
capture: [allowable, allocable, reasonable]
label: "Expenditure approved"
detail: "Allowable: {allowable}, Allocable: {allocable}, Reasonable: {reasonable}"
icon: lucide:check
severity: success
flagged:
on: transition
transition: flag
capture: [flagReason]
label: "Expenditure flagged"
detail: "{flagReason}"
icon: lucide:flag
severity: warning
rejected:
on: transition
transition: reject
capture: [rejectionReason]
label: "Expenditure rejected"
detail: "{rejectionReason}"
icon: lucide:x-circle
severity: error
processed:
on: transition
transition: process
label: "Payment processed"
icon: lucide:credit-card
severity: success
disallowed:
on: transition
transition: disallow
capture: [disallowReason]
label: "Cost disallowed"
detail: "{disallowReason}"
icon: lucide:ban
severity: error
costTestFailed:
on: fieldChange
field: allowable
condition: "allowable == false"
capture: [amount, description]
label: "COST TEST FAILURE — not allowable"
detail: "{amount}: {description}"
icon: lucide:alert-octagon
severity: error
roles: [spoOfficer, grantAccountant, admin]
permissions:
list: [authenticated]
get: [authenticated]
create: [pi, departmentAdmin, grantAccountant, admin]
update: [grantAccountant, spoOfficer, admin]
delete: [admin]
fields:
allowable: { write: [spoOfficer, grantAccountant, admin] }
allocable: { write: [spoOfficer, grantAccountant, admin] }
reasonable: { write: [spoOfficer, grantAccountant, admin] }
# ─────────────────────────────────────────────────────────────────────────
Commitment:
description: >
An effort commitment for one person on one award for one period. The
cross-award 100% cap is enforced by the effortOvercommitment reject rule.
object:
id: { type: uuid, primaryKey: true }
awardId: { type: uuid, required: true, immutable: true }
personnelId: { type: uuid, required: true, immutable: true,
privacy: { classification: personalData, pii: true, subjectIdentifier: true } }
personnelName: { type: string, required: true, maxLength: 120,
privacy: { classification: personalData, pii: true } }
role: { $ref: '#/enums/PersonnelRole' }
committedPercent: { type: percentage, required: true, min: 0, max: 100 }
actualPercent: { type: percentage, min: 0, max: 100 }
period: { type: string, required: true, maxLength: 40 }
status: { $ref: '#/enums/EffortStatus', default: committed }
certifiedById: { type: string, maxLength: 100 }
certifiedByName: { type: string, maxLength: 120 }
certifiedAt: { type: datetime }
$merge: { $ref: '#/objects/Timestamp' }
relationships:
award: { type: belongsTo, target: Award, foreignKey: awardId, onDelete: restrict }
indexes:
- { fields: [awardId] }
- { fields: [personnelId] }
- { fields: [status] }
computed:
variance:
type: percentage
formula: "if(actualPercent == null, 0, abs(committedPercent - actualPercent))"
stored: true
updateOn: [committedPercent, actualPercent]
varianceExcessive:
type: boolean
formula: "variance > 5"
stored: true
updateOn: [variance]
stateMachine:
field: status
initial: committed
states:
committed:
transitions:
adjust:
to: adjusted
guards: [pi, departmentAdmin, admin]
requiredFields: [actualPercent]
adjusted:
transitions:
certify:
to: certified
guards: [pi, admin]
# Certifier identity is captured from the authenticated session,
# not typed in by the caller.
sets:
certifiedAt: "now()"
certifiedById: "$user.id"
certifiedByName: "$user.username"
adjust:
to: adjusted
guards: [pi, departmentAdmin, admin]
requiredFields: [actualPercent]
certified:
final: true
features:
audit: true
history:
events:
committed:
on: create
capture: [personnelName, committedPercent, period, role]
label: "{personnelName} — {committedPercent}% effort committed"
detail: "Role: {role}, Period: {period}"
icon: lucide:user-plus
severity: info
adjusted:
on: transition
transition: adjust
capture: [personnelName, actualPercent, committedPercent]
label: "{personnelName} effort adjusted to {actualPercent}%"
detail: "Original commitment: {committedPercent}%"
icon: lucide:edit
severity: info
certified:
on: transition
transition: certify
capture: [personnelName, actualPercent, certifiedByName, certifiedAt]
label: "Effort certified for {personnelName} at {actualPercent}%"
detail: "Certified by {certifiedByName}"
icon: lucide:check-circle
severity: success
varianceDetected:
on: fieldChange
field: varianceExcessive
condition: "varianceExcessive == true"
capture: [personnelName, committedPercent, actualPercent, variance]
label: "EFFORT VARIANCE — {personnelName}"
detail: "Committed {committedPercent}%, actual {actualPercent}% (variance: {variance}%). Cost transfer may be required per 2 CFR §200.430."
icon: lucide:alert-triangle
severity: error
permissions:
list: [authenticated]
get: [authenticated]
create: [spoOfficer, departmentAdmin, admin]
update: [pi, departmentAdmin, spoOfficer, admin]
delete: [admin]
fields:
actualPercent: { write: [pi, departmentAdmin, admin] }
certifiedById: { write: [] } # transition-set only
certifiedByName: { write: [] }
certifiedAt: { write: [] }
Audit scenarios
Single Audit: budget transfer threshold
# Resolve the award
curl -s "$BASE/api/v1/grants/awards?filter[awardNumber]=NSF-2024-1234" \
-H "Authorization: Bearer $TOKEN" | jq -r '.data.items[0].id'
# Question 1: "Were all transfers within the threshold?"
# The rules log IS the compliance evidence trail (§22). Every evaluation of the
# federalTransferThreshold rule — fired or not — is recorded.
curl -s "$BASE/api/v1/grants/rules/federalTransferThreshold/log?fired=true" \
-H "Authorization: Bearer $TOKEN"
# Question 2: "Where is the sponsor approval?"
# Breaching-but-approved transfers are logged by the companion rule, and the
# transferApproved history event carries the approval reference and date.
curl -s "$BASE/api/v1/grants/rules/transferThresholdWithApproval/log" \
-H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/v1/grants/transfers/history?event=transferApproved" \
-H "Authorization: Bearer $TOKEN"
# All transfers on the award, with completion status
curl -s "$BASE/api/v1/grants/transfers?filter[awardId]={id}&sort=-createdAt" \
-H "Authorization: Bearer $TOKEN"
The stronger claim this enables: a transfer that would breach without approval
cannot complete. The rule rejects it at 422 RULE_VIOLATION before the
write commits. The auditor doesn’t find a detected violation; they find a
control that made the violation impossible, plus a log proving the control ran.
Effort certification variance
# Documented history stream params are event / after / before (not since/until)
curl -s "$BASE/api/v1/grants/commitments/history?event=varianceDetected&after=2025-07-01&before=2026-06-30" \
-H "Authorization: Bearer $TOKEN"
# Overcommitment attempts that were blocked (evidence the 100% cap is enforced)
curl -s "$BASE/api/v1/grants/rules/effortOvercommitment/log?fired=true" \
-H "Authorization: Bearer $TOKEN"
Expenditure allowability (three-part cost test)
curl -s "$BASE/api/v1/grants/expenditures/history?event=approved" \
-H "Authorization: Bearer $TOKEN"
curl -s "$BASE/api/v1/grants/expenditures?filter[allowable]=false" \
-H "Authorization: Bearer $TOKEN"