Municipal Permitting & Licensing
The flagship "kitchen-sink" seed: a city permitting authority — permits, inspections, payments, budgets, and public notices — designed to exercise the full ADL v0.3 feature surface.
- Resources
- SiteConfig, Department, PermitType, Applicant, Inspector, Permit, Inspection, Payment, PermitTag, PermitReviewer, PublicNotice, BudgetWorksheet, BudgetLine
- Roles
- admin, clerk, reviewer, inspector, inspectorLead, financeOfficer, privacyOfficer, applicant, authenticated, owner, *
- Features (57)
- CRUDrelationshipsbelongsToManycompositePrimaryKeyselfReferentialrichEnumsstateMachinecomputedFieldsaggregationFieldsreactiveFieldscrossFieldValidationcrossResourceValidationsoftDeleteauditversioningsearchexportimportcachingscopesconditionalPermissionsfieldLevelPermissionsaelPermissionshistoryrulesEnginenotificationsdigestNotificationsescalationChainsscheduledTaskstieredSchedulesworkflowsviewsreportsdashboardstemplatesintegrationswebhooksprivacygdprencryptionconsentretentionlocalizationi18nblocksiconsfontscomponentsrepeatersmediasvgsheetModedynamicFieldsindexessingletoncustomOperationsallContentTypes
A “kitchen-sink” ADL seed package that models a municipal permitting & licensing authority, designed to exercise the full ADL v0.3 feature surface.
It serves two purposes:
- Conformance test. If a Kalos runtime can parse, bootstrap, migrate, and serve this domain end-to-end, it implements the full v0.3 surface area.
- Worked documentation. Every construct appears in a realistic context, so the file doubles as a reference for how the features compose.
The domain story: a city agency accepts permit/license applications from residents and businesses, routes them through review and inspection, charges fees, issues official documents, publishes public notices, files reports to a state registry, and tracks departmental budgets, all under GDPR/CCPA-style privacy controls and multilingual public access.
Files
| File | Purpose |
|---|---|
| domain.yaml | The complete ADL v0.3 domain definition |
| seed.yaml | Roles, users, assignments, and sample records |
| manifest.json | Package metadata and feature list |
| README.md | This document, including the feature-coverage matrix |
| VERSIONS.md | Version history |
| LICENSE.md | MIT license |
Resources
| Resource | Role in the model | Notable features it carries |
|---|---|---|
| SiteConfig | Agency-wide configuration | Singleton, component, svg, font, color, json |
| Department | Org unit owning permits and inspectors | timezone, reactive cross-resource computed field, export/search |
| PermitType | Catalog of permit/license types | Self-referential tree, icon, markdown, money, caching |
| Applicant | Resident/business/contractor applying for permits | Heavy PII + privacy, password/token/bytes/set/language, soft delete, versioning, import, owner role |
| Inspector | Field inspector | belongsTo Department, certification expiry (feeds a relative schedule) |
| Permit | The core lifecycle resource | State machine, history, rules, workflow, templates, integrations, computed aggregations, conditional + field-level RBAC, scopes, nearly every scalar type |
| Inspection | Field inspection on a permit | Child state machine, repeater checklist, webhook update target |
| Payment | Fee payment | State machine, aggregation source, financial PII |
| PermitTag | Free-form tags | belongsToMany (junction PermitTagLink, with timestamps) |
| PermitReviewer | Reviewer-to-permit assignment | Composite primary key resource |
| PublicNotice | Public-facing bulletins | blocks (incl. custom blocks), media, svg, icon, custom operations |
| BudgetWorksheet | Departmental budget | Sheet mode: dynamic columns, reactive computed grandTotal |
| BudgetLine | Budget rows | Reactive same-record formula chain + cross-record SUMIF, schemaChange history |
Roles
admin, clerk, reviewer, inspector, inspectorLead, financeOfficer, privacyOfficer, applicant, plus the built-in identifiers authenticated, owner, and *.
Running
Point the seeder at this package and start the server:
{
"seeder": { "seed_file": "seeds/polis/seed.yaml", "run_on_start": true }
}
The seed is idempotent (§32.8). Demo passwords: admin = Admin123!, all others = Demo123!.
A handful of environment variables are referenced by integrations/privacy and can be left unset in development (the runtime falls back or disables the relevant subsystem): ENCRYPTION_KEY, STATE_CLIENT_ID, STATE_CLIENT_SECRET, INSPECTION_WEBHOOK_SECRET, S3_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SMTP_HOST, SMTP_USER, SMTP_PASS, AGENCY_NAME, AGENCY_ADDRESS, AGENCY_LOGO_PATH, JURISDICTION_CODE, ARCHIVE_PATH.
Feature-Coverage Matrix
Each spec chapter mapped to where it is exercised in domain.yaml.
| § | Chapter | Where it appears |
|---|---|---|
| 6 | Document Structure | domain block with config, properties, objects, enums, resources, blocks, icons, fonts |
| 7 | Type System (all 37 types) | Every type appears at least once (see “Type coverage” below) |
| 7.7 | Field constraints | required, unique, default, immutable, nullable, pattern, trim, lowercase, uppercase, min/max, precision/scale, writeOnly/internal/hidden/requestable, mask |
| 7.8 | Auto-generated fields | autoNow: create/always (Timestamp), autoincrement (Applicant.applicantNumber, Permit.caseSequence), token, slug |
| 8 | Relationships | belongsTo (all four onDelete modes), hasMany (with orderBy), belongsToMany (Permit↔PermitTag via PermitTagLink), self-referential (PermitType.parent/children), composite PK (PermitReviewer), immutable FKs |
| 9 | Enumerations | Rich enums (PermitStatus, InspectionStatus, …) with label/description/color/icon/final; inline simple enums (Inspection.inspectionType, Payment.method, BudgetLine.category) |
| 11 | Permissions & RBAC | Operation-level role arrays + AEL expressions (Permit.get), field-level read/write/mask, conditional-by-state (Permit.permissions.conditional), transition permissions, owner role |
| 12 | State Machines | Permit, Inspection, Payment: guards, requiredFields, sets (now()/null), onEnter/onExit hooks, final states |
| 13 | Computed Fields | Virtual (Permit.isExpired, displayLabel), materialized chain (balanceDue ← feesCollected), aggregation (feesCollected, inspectionCount, permitCount), reactive (Department.totalAssessedFees, BudgetLine.categoryTotal) |
| 14 | Validation | Cross-field rules (Permit.geoPaired, decisionNeedsReason), when: create, cross-resource query()/exists() (Applicant.uniqueSsn, Permit.applicantActive) |
| 15 | Resource Features | softDelete, audit, versioning, search, export, import, caching+cacheTtlSeconds, transactions, validationMode |
| 17 | API Configuration | config.api.prefix + aliases, singleton (SiteConfig), customOperations (PublicNotice.publish/archive) |
| 18 | Content & Media | Custom blocks (Callout, NoticeBanner), icons sets (lucide CDN + local seal), fonts (google + system), component, repeater, media, svg |
| 19 | Indexes | Single, composite, named, and unique: true indexes across most resources |
| 20 | Scopes | Permit.scopes (default open, ownership scope :currentUserId, all), PublicNotice.scopes |
| 21 | History of Change | Permit, Inspection, Payment, BudgetLine: on: create/update/transition/fieldChange/schemaChange, capture, label/detail, icon, severity, role-scoped events, conditional events, display, retention |
| 22 | Rules Engine | reject/warn/log actions, aggregation/lookup/temporal conditions, messageData, regulation, priority, dependsOn, group + _groups |
| 23 | Notifications | Standalone (on: history), conditional, type: digest, escalation chains, throttle, priority, recipients by field/roles |
| 24 | Scheduled Tasks | Recurring (expirePermits, auditRetentionCleanup), relative tiered (renewalReminders), action: report/transition/update/archive, skipIf |
| 25 | Workflows | permitReviewProcess: action/wait/decide/parallel steps, timeout, onError, $trigger/$event/$steps data flow |
| 26 | Views / Reports / Dashboards | activePermits view (computed columns + highlight), complianceQuarterly report (parameters, metrics + table), operationsOverview dashboard (gauge/counter/scorecard/breakdown/timeSeries/timeline) |
| 27 | Templates / Documents | permitCertificate (trigger, PDF, data, layout, {#each} body, attachTo, notification), denialLetter |
| 28 | Sheet Mode | BudgetWorksheet/BudgetLine: dynamicFields, computedFields.reactive/overridable/crossRecord, on: schemaChange history |
| 29 / 32 | Auth + Seeder | seed.yaml roles/users/assignments/data, deterministic IDs (allowUserProvided), state bypass (Permit #2 created approved), m2m link action |
| 33 | Privacy / GDPR | Domain privacy block (classifications, subjectIdentifier, anonymization, encryption, consent, retention) + field-level pii/sensitive/encrypted/subjectIdentifier on Applicant and Payment |
| 34 | Localization / i18n | localization block: supported locales, fallback chain, per-locale date/number formats, inline translations |
| 35 | External Integrations | REST outbound (statePermitRegistry, OAuth2 + retry + circuit breaker + rate limit + dead-letter), webhook inbound (sponsorWebhook, HMAC + replay protection), S3, SMTP |
| 36 | AEL | Used throughout: permission expressions, guards, computed formulas, validation rules, rule conditions, scope/notification conditions |
| 37 | OpenAPI Generation | Emergent: generated from the model; rich-enum x-enum-* extensions come from the enums above |
Type coverage (Chapter 7, all 37)
Tier 0: string, text, integer, decimal, boolean, date, datetime, uuid, email, url ·
Tier 1: float, timestamp, slug, json, markdown, autoincrement ·
Tier 2: phone, time, password, token, array ·
Tier 3: money, percentage, currency, country, language, timezone, color, bytes, set ·
Content: media, svg, blocks, icon, font, component, repeater
A coverage script (run during authoring) confirms all 37 types and 90+ feature constructs are present.
Notes & Assumptions
- Lua hooks. The
onEnter/onExithook names (acknowledgeSubmission,pushToSearchIndex) reference external Lua scripts (§31). The domain declares the hooks; the scripts themselves are deployment-specific and not included here. - Media files.
seed.yamlreferencesfile://assets/.... Provide those assets, switch to inline base64, or upload via the media endpoint after boot if your seeder does not resolvefile://. - Version markers. Everything targets ADL 0.3.1, built specifically against the v0.3 Intelligence-Layer chapters.
Non-Goals
This seed is breadth-first by design. It favors touching every feature over deep realism in any one area. It is not a production permitting system: there is no payment-gateway implementation behind the Payment resource, no real PDF rendering engine, no GIS, and the integration endpoints point at example hosts. Those are intentionally left as the boundary between the declarative model and the runtime/plugins that implement it.
Domain definition
View raw (1946 lines)Show domain.yaml
# ============================================================================
# polis/domain.yaml
#
# POLIS — Municipal Permitting & Licensing Authority
# ADL version: 0.3.0
#
# A "kitchen-sink" conformance seed. This single domain is intentionally
# designed to exercise EVERY feature of the ADL v0.3 specification at least
# once. It is meant as a living conformance test and as worked documentation:
# if the runtime can parse, bootstrap, and serve this domain end-to-end, it
# implements the full v0.3 surface area.
#
# The README.md ships a feature-coverage matrix mapping each spec chapter to
# the exact construct(s) below that exercise it.
#
# Domain story: a city agency that accepts permit/license applications from
# residents and businesses, routes them through review and inspection, charges
# fees, issues official documents, publishes public notices, files reports to
# state systems, and tracks departmental budgets — under GDPR/CCPA-style
# privacy controls and multilingual public access.
# ============================================================================
domain:
name: polis
version: "1.0.0"
description: >
Municipal permitting and licensing authority. Models the full lifecycle of
a permit from application through review, inspection, payment, issuance,
expiry, and renewal — with departmental budgets, public notices, external
system integrations, and privacy/retention controls. Authored as a complete
ADL v0.3 conformance exemplar.
# --- §17.8 API prefix & aliases ------------------------------------------
config:
api:
prefix: /api/v1/polis
aliases:
- /api/v1/permits
# --- §26.5 Domain locale (formatting defaults) ---------------------------
locale:
currency: USD
dateFormat: "MMM d, yyyy"
numberFormat:
thousandsSeparator: ","
decimalSeparator: "."
# --- §34 Localization / i18n ---------------------------------------------
localization:
defaultLocale: en
supportedLocales: [en, es, fr, zh]
fallbackChain: [requestedLocale, en]
dateFormat:
en: "MMM d, yyyy"
es: "d 'de' MMM 'de' yyyy"
fr: "d MMM yyyy"
zh: "yyyy年M月d日"
numberFormat:
en: { thousands: ",", decimal: ".", currencySymbol: "$", currencyPosition: before }
es: { thousands: ".", decimal: ",", currencySymbol: "$", currencyPosition: before }
fr: { thousands: " ", decimal: ",", currencySymbol: "$", currencyPosition: after }
zh: { thousands: ",", decimal: ".", currencySymbol: "$", currencyPosition: before }
translations:
es:
resources:
Permit:
label: "Permiso"
plural: "Permisos"
fields:
permitNumber: "Número de Permiso"
status: "Estado"
enums:
PermitStatus:
approved: "Aprobado"
rejected: "Rechazado"
under_review: "En Revisión"
validation:
messages:
required: "es obligatorio"
fr:
resources:
Permit:
label: "Permis"
plural: "Permis"
# --- §33 Privacy / GDPR --------------------------------------------------
privacy:
enabled: true
classifications:
personalData:
description: "Standard personal data (GDPR Art. 6)"
retention: 7 years
sensitiveData:
description: "Special category data (GDPR Art. 9)"
retention: 10 years
financialData:
description: "Payment and financial records"
retention: 10 years
subjectIdentifier:
primary: email
alternates: [ssn, applicantNumber]
anonymization:
method: anonymize
rules:
default: "Redacted"
firstName: "Redacted"
lastName: "Redacted"
email: "{$pseudonym}@redacted.local"
dateOfBirth: "$fuzzyDate(5 years)"
ssn: "$hash6"
applicantNumber: "$preserve"
encryption:
provider: aes256gcm
keySource: environment
keyVariable: "${ENCRYPTION_KEY}"
keyRotation: 365 days
consent:
- purpose: permit_processing
description: "Process the application and issue permits"
required: true
withdrawable: false
- purpose: marketing_updates
description: "Send optional service announcements"
required: false
withdrawable: true
resources: [Applicant]
retention:
- category: personalData
duration: 7 years
action: anonymize
from: lastActivity
- category: sensitiveData
duration: 10 years
action: archive
from: recordCreation
- category: financialData
duration: 10 years
action: delete
from: lastActivity
# =========================================================================
# §6.7 Reusable properties (atomic type aliases)
# =========================================================================
properties:
emailAddr:
type: email
lowercase: true
maxLength: 255
phoneNum:
type: phone
pattern: "^\\+?[1-9]\\d{1,14}$"
slugField:
type: slug
unique: true
postalCode:
type: string
pattern: "^[0-9]{5}(-[0-9]{4})?$"
maxLength: 10
# =========================================================================
# §6.7 / §10 Reusable objects (mixins / composition)
# =========================================================================
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete:
deletedAt: { type: datetime }
AuditMeta:
createdById: { type: uuid, internal: true }
createdByName: { type: string, maxLength: 200 }
Address:
line1: { type: string, maxLength: 255, required: true }
line2: { type: string, maxLength: 255 }
city: { type: string, maxLength: 120, required: true }
state: { type: string, maxLength: 2, uppercase: true }
postalCode: { $ref: '#/properties/postalCode' }
country: { type: country, default: "US" }
# =========================================================================
# §9 Enumerations (rich — label/description/color/icon/final)
# =========================================================================
enums:
PermitStatus:
values:
draft: { label: "Draft", description: "Applicant is preparing the application", color: "#94a3b8", icon: "lucide:pencil" }
submitted: { label: "Submitted", description: "Submitted for intake review", color: "#3b82f6", icon: "lucide:send" }
under_review: { label: "Under Review", description: "Plan examiners are reviewing", color: "#f59e0b", icon: "lucide:search" }
revision_requested: { label: "Revision Requested", description: "Changes requested from applicant", color: "#f97316", icon: "lucide:rotate-ccw" }
inspection_scheduled: { label: "Inspection Scheduled", description: "Awaiting field inspection", color: "#8b5cf6", icon: "lucide:calendar-clock" }
approved: { label: "Approved", description: "Permit issued and active", color: "#10b981", icon: "lucide:badge-check" }
rejected: { label: "Rejected", description: "Application denied", color: "#ef4444", icon: "lucide:x-circle", final: true }
expired: { label: "Expired", description: "Permit lapsed past expiry", color: "#6b7280", icon: "lucide:hourglass", final: true }
withdrawn: { label: "Withdrawn", description: "Withdrawn by applicant", color: "#64748b", icon: "lucide:undo-2", final: true }
InspectionStatus:
values:
scheduled: { label: "Scheduled", color: "#3b82f6", icon: "lucide:calendar" }
in_progress: { label: "In Progress", color: "#f59e0b", icon: "lucide:loader" }
passed: { label: "Passed", color: "#10b981", icon: "lucide:check", final: true }
failed: { label: "Failed", color: "#ef4444", icon: "lucide:x", final: true }
cancelled: { label: "Cancelled", color: "#6b7280", icon: "lucide:ban", final: true }
PaymentStatus:
values:
pending: { label: "Pending", color: "#f59e0b" }
paid: { label: "Paid", color: "#10b981", final: true }
refunded: { label: "Refunded", color: "#8b5cf6", final: true }
failed: { label: "Failed", color: "#ef4444" }
ApplicantType:
values:
resident: { label: "Resident", color: "#3b82f6" }
business: { label: "Business", color: "#8b5cf6" }
contractor: { label: "Contractor", color: "#f59e0b" }
government: { label: "Government", color: "#64748b" }
RiskLevel:
values:
low: { label: "Low", color: "#10b981" }
moderate: { label: "Moderate", color: "#f59e0b" }
high: { label: "High", color: "#f97316" }
critical: { label: "Critical", color: "#ef4444" }
NoticeSeverity:
values:
info: { label: "Info", color: "#3b82f6" }
notice: { label: "Notice", color: "#f59e0b" }
warning: { label: "Warning", color: "#f97316" }
urgent: { label: "Urgent", color: "#ef4444" }
# =========================================================================
# §18.4 Custom block definitions (for `blocks` content fields)
# =========================================================================
blocks:
Callout:
label: "Callout"
hasContent: true
props:
style: { type: enum, values: [info, warning, tip, danger], default: info }
title: { type: string, maxLength: 120, required: false }
NoticeBanner:
label: "Notice Banner"
hasContent: false
props:
heading: { type: string, required: true }
severity: { type: enum, values: [info, notice, warning, urgent], default: info }
ctaText: { type: string, default: "Read more" }
ctaUrl: { type: url, required: false }
# =========================================================================
# §18.5 Icon sets
# =========================================================================
icons:
lucide:
source: cdn
url: "https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/"
prefix: lucide
format: svg
seal:
source: local
path: ./icons/
prefix: seal
format: svg
# =========================================================================
# §18.6 Font sources
# =========================================================================
fonts:
google:
source: google-fonts
preload: [Inter, "Source Serif 4", "JetBrains Mono"]
system:
source: system
families: [Arial, Helvetica, Georgia, Menlo, monospace]
# =========================================================================
# §22 Domain-level RULES (cross-resource business policies)
# =========================================================================
rulesConfig:
logAllEvaluations: false
logRetentionDays: 730
rules:
_groups:
buildingCode:
description: "Local building-code enforcement rules"
rules: [inspectionRequiredBeforeApproval, highRiskNeedsSeniorReviewer]
enabled: true
finance:
description: "Fee and payment controls"
rules: [feesMustBePaidBeforeIssuance, largeRefundWarning]
enabled: true
feesMustBePaidBeforeIssuance:
description: "A permit cannot be approved while a balance is owed."
group: finance
priority: 10
trigger:
resource: Permit
on: [transition]
transition: approve
condition: >
sum(Payment, 'amount', permitId == $record.id && status == 'paid')
< $record.totalFeeAssessed
action: reject
severity: error
message: "Permit {permitNumber} has an outstanding balance of {balanceOwed} and cannot be issued."
messageData:
balanceOwed: >
$record.totalFeeAssessed
- sum(Payment, 'amount', permitId == $record.id && status == 'paid')
regulation: "Municipal Code §14.2 — Fee Collection"
inspectionRequiredBeforeApproval:
description: "At least one passed inspection is required before issuance."
group: buildingCode
priority: 20
dependsOn: feesMustBePaidBeforeIssuance
trigger:
resource: Permit
on: [transition]
transition: approve
condition: >
count(Inspection, permitId == $record.id && status == 'passed') == 0
action: reject
severity: error
message: "Permit {permitNumber} requires at least one passed inspection before approval."
regulation: "IBC §110 — Inspections"
notification:
channels: [email]
recipients:
field: createdById
template:
subject: "Approval blocked — {permitNumber}"
body: "An inspection must pass before {permitNumber} can be approved."
highRiskNeedsSeniorReviewer:
description: "High/critical risk permits must have a senior reviewer assigned."
group: buildingCode
priority: 30
trigger:
resource: Permit
on: [transition]
transition: approve
condition: "$record.riskLevel == 'high' || $record.riskLevel == 'critical'"
condition: >
count(PermitReviewer, permitId == $record.id && senior == true) == 0
action: reject
severity: error
message: "High-risk permit {permitNumber} requires a senior reviewer."
regulation: "Municipal Code §14.9 — Review Standards"
largeRefundWarning:
description: "Flag refunds over $5,000 for finance review (non-blocking)."
group: finance
priority: 100
trigger:
resource: Payment
on: [create, update]
fields: [amount, status]
condition: "$record.status == 'refunded'"
condition: "$record.amount > 5000"
action: warn
severity: warning
message: "Refund of {amount} on payment {id} exceeds the $5,000 review threshold."
regulation: "Municipal Code §14.4 — Refunds"
history:
event: "largeRefundFlagged"
label: "Large refund flagged for review"
duplicateApplicantSsn:
description: "Log when an SSN matches an existing applicant (fraud signal)."
priority: 100
trigger:
resource: Applicant
on: [create]
condition: >
count(Applicant, ssn == $record.ssn && id != $record.id) > 0
action: log
severity: info
message: "Applicant {applicantNumber} shares an SSN with an existing record."
# =========================================================================
# §23 NOTIFICATIONS (standalone, conditional, digest, escalation)
# =========================================================================
notifications:
permitApprovedApplicant:
on: history
event: approved
channels: [email]
recipients:
field: createdById
template:
subject: "Your permit {permitNumber} has been approved"
body: "Good news — permit {permitNumber} is now active. It expires on {expiresAt}."
highValuePermitAlert:
on: history
event: submitted
condition: "$record.totalFeeAssessed > 25000"
channels: [email, slack]
recipients:
roles: [financeOfficer]
template:
subject: "High-value permit submitted — ${totalFeeAssessed}"
body: "Permit {permitNumber} was submitted with assessed fees of ${totalFeeAssessed}."
priority: high
throttle: 1h
dailyIntakeDigest:
type: digest
schedule: "daily at 07:00"
channels: [email]
recipients:
roles: [clerk, admin]
query:
resource: "*"
history: true
filter: "severity == 'warning' || severity == 'error'"
since: 24h
template:
subject: "Daily permitting summary — {$date}"
body: |
## Last 24 hours
**Errors:** {errorCount}
**Warnings:** {warningCount}
{#each errors}
- [{event}] {label} — {createdAt}
{/each}
emptyBehavior: skip
inspectionFailureEscalation:
on: history
event: inspectionFailed
channels: [email]
recipients:
field: createdById
template:
subject: "Inspection failed — {permitNumber}"
body: "Inspection for {permitNumber} did not pass. Please review the report."
priority: high
escalation:
- after: 48h
channels: [email, slack]
recipients:
roles: [inspectorLead]
template:
subject: "ESCALATION — failed inspection unresolved — {permitNumber}"
body: "Inspection failure on {permitNumber} has not been resolved in 48h."
priority: urgent
- after: 96h
channels: [email, sms]
recipients:
roles: [admin]
template:
subject: "FINAL ESCALATION — {permitNumber}"
body: "Inspection failure on {permitNumber} remains unresolved after 96h."
priority: critical
# =========================================================================
# §24 SCHEDULED TASKS (recurring, relative-tiered, batched, report)
# =========================================================================
schedules:
expirePermits:
description: "Expire active permits past their expiry date."
recurrence: daily at 02:00
source:
resource: Permit
filter: "status == 'approved' && expiresAt < now()"
action: transition
transition: expire
flagStalePermits:
description: "Flag applications stuck in review for 30+ days."
recurrence: daily at 03:00
source:
resource: Permit
filter: "status == 'under_review' && daysOld(updatedAt) > 30 && stale == false"
action: update
input:
stale: true
renewalReminders:
description: "Tiered reminders before a permit expires."
type: relative
source:
resource: Permit
filter: "status == 'approved'"
relativeTo: expiresAt
tiers:
- daysUntil: 60
action: notify
tag: renew60
channels: [email]
recipients:
field: createdById
template:
subject: "Permit {permitNumber} expires in 60 days"
body: "Renew {permitNumber} before {expiresAt} to avoid a lapse."
- daysUntil: 14
action: notify
tag: renew14
channels: [email, sms]
recipients:
field: createdById
roles: [clerk]
template:
subject: "Permit {permitNumber} expires in 14 days"
body: "Action required: renew {permitNumber} before {expiresAt}."
- daysUntil: 0
action: transition
transition: expire
notification:
channels: [email]
recipients:
field: createdById
template:
subject: "Permit {permitNumber} has expired"
body: "{permitNumber} expired on {expiresAt}."
quarterlyComplianceReport:
description: "Generate and email the quarterly compliance report."
recurrence: quarterly on 1st at 06:00
action: report
report: complianceQuarterly
recipients:
roles: [admin, financeOfficer]
channels: [email]
format: pdf
auditRetentionCleanup:
description: "Archive audit rows past retention."
recurrence: monthly on 1st at 02:30
source:
resource: "*"
table: "_audit"
filter: "daysOld(created_at) > 730"
action: archive
destination: "${ARCHIVE_PATH}/audit/{year}/{month}/"
format: csv
deleteAfterArchive: true
# =========================================================================
# §25 WORKFLOWS (cross-resource orchestration)
# =========================================================================
workflows:
permitReviewProcess:
description: "End-to-end review: assign reviewer, await review, schedule inspection, await result."
trigger:
resource: Permit
event: transition
transition: submit
condition: "$trigger.record.totalFeeAssessed >= 0"
steps:
- name: openReview
action: transition
resource: Permit
target: $trigger.record.id
transition: beginReview
then: routeByRisk
- name: routeByRisk
decide:
- condition: "$trigger.record.riskLevel == 'critical' || $trigger.record.riskLevel == 'high'"
then: parallelChecks
- default: true
then: scheduleInspection
- name: parallelChecks
parallel:
all: true
steps:
- name: awaitSeniorReview
wait:
resource: Permit
event: transition
transition: seniorSignoff
filter:
id: $trigger.record.id
- name: awaitFinanceClear
wait:
resource: Payment
event: transition
transition: markPaid
filter:
permitId: $trigger.record.id
then: scheduleInspection
- name: scheduleInspection
action: create
resource: Inspection
input:
permitId: $trigger.record.id
inspectionType: routine
then: awaitInspection
- name: awaitInspection
wait:
resource: Inspection
event: transition
transition: pass
filter:
permitId: $trigger.record.id
timeout:
duration: 30d
then: escalateInspection
condition: "$event.record.status == 'passed'"
then: finalizeApproval
else: requestRevision
- name: finalizeApproval
action: transition
resource: Permit
target: $trigger.record.id
transition: approve
then: end
- name: requestRevision
action: transition
resource: Permit
target: $trigger.record.id
transition: requestRevision
then: end
- name: escalateInspection
action: notify
resource: Permit
target: $trigger.record.id
notification:
channels: [email]
recipients:
roles: [inspectorLead]
template:
subject: "Inspection overdue — {permitNumber}"
body: "No inspection result for {permitNumber} within 30 days."
then: end
timeout:
duration: 120d
action: transition
resource: Permit
target: $trigger.record.id
transition: expire
onError:
action: notify
resource: Permit
target: $trigger.record.id
notification:
channels: [email]
recipients:
roles: [admin]
template:
subject: "Workflow error — permitReviewProcess"
body: "Workflow instance {$workflow.id} failed at step {$workflow.failedStep}."
# =========================================================================
# §27 TEMPLATES / DOCUMENTS
# =========================================================================
templates:
permitCertificate:
description: "Official permit certificate generated on approval."
trigger:
resource: Permit
event: transition
transition: approve
format: pdf
filename: "Permit-{permitNumber}-{$date}.pdf"
permissions:
generate: [clerk, admin]
view: [authenticated]
data:
permit: $record
applicant:
source: Applicant
filter: "id == $record.applicantId"
single: true
inspections:
source: Inspection
filter: "permitId == $record.id && status == 'passed'"
sort: completedAt asc
agency:
static:
name: "${AGENCY_NAME}"
address: "${AGENCY_ADDRESS}"
layout:
pageSize: letter
margins: { top: 1in, right: 1in, bottom: 1in, left: 1in }
header:
logo: "${AGENCY_LOGO_PATH}"
text: "Department of Permits & Licensing"
footer:
left: "Generated {$datetime}"
center: "Page {$page} of {$pages}"
right: "OFFICIAL"
body: |
# Certificate of Permit
**Permit Number:** {permit.permitNumber}
**Issued:** {$date}
## Permit Holder
**Name:** {applicant.firstName} {applicant.lastName}
**Email:** {applicant.email}
## Details
| Field | Value |
|-------|-------|
| Type | {permit.permitTypeName} |
| Risk Level | {permit.riskLevel} |
| Assessed Fees | {permit.totalFeeAssessed | money} |
| Expires | {permit.expiresAt | date} |
## Passed Inspections
{#each inspections}
- {inspectionType} — passed {completedAt | date}
{/each}
attachTo:
resource: Permit
recordId: $record.id
field: certificateDocId
notification:
channels: [email]
recipients:
field: createdById
template:
subject: "Permit certificate — {permit.permitNumber}"
body: "Your official permit certificate is attached."
denialLetter:
description: "Denial letter generated when a permit is rejected."
trigger:
resource: Permit
event: transition
transition: reject
format: pdf
filename: "Denial-{permitNumber}.pdf"
permissions:
generate: [reviewer, admin]
view: [authenticated]
data:
permit: $record
layout:
pageSize: letter
body: |
# Notice of Denial
Permit **{permit.permitNumber}** has been denied.
**Reason:** {permit.decisionReason}
You may appeal within 30 days.
# =========================================================================
# §35 EXTERNAL INTEGRATIONS (REST out, webhook in, S3, SMTP)
# =========================================================================
integrations:
statePermitRegistry:
description: "Report issued permits to the state registry."
type: rest
baseUrl: "https://api.state.example.gov/permits/v1"
auth:
type: oauth2_client_credentials
tokenUrl: "https://auth.state.example.gov/token"
clientId: "${STATE_CLIENT_ID}"
clientSecret: "${STATE_CLIENT_SECRET}"
scopes: ["permits.write"]
tokenCache: true
retry:
maxAttempts: 3
backoff: exponential
baseDelay: 1s
maxDelay: 30s
retryOn: [500, 502, 503, 504, 429]
circuitBreaker:
enabled: true
failureThreshold: 5
successThreshold: 2
timeout: 60s
rateLimit:
requestsPerSecond: 10
burstSize: 20
onExceeded: queue
deadLetter:
enabled: true
maxRetries: 3
notifyOn: [integration_failure]
operations:
reportIssuance:
trigger:
resource: Permit
event: transition
transition: approve
request:
method: POST
path: "/issuances"
headers:
Content-Type: "application/json"
body:
permit_number: $record.permitNumber
jurisdiction: "${JURISDICTION_CODE}"
issued_at: "$formatDate($now(), 'yyyy-MM-dd')"
fee_total: $record.totalFeeAssessed
onSuccess:
action: update
resource: Permit
target: $record.id
input:
stateRegistryId: $response.registry_id
history:
event: "reportedToState"
label: "Reported to state registry — {stateRegistryId}"
onError:
action: notify
resource: Permit
target: $record.id
sponsorWebhook:
description: "Receive inspection results from the field-inspection vendor."
type: webhook
path: /webhooks/field-results
auth:
type: hmac_signature
secret: "${INSPECTION_WEBHOOK_SECRET}"
header: X-Webhook-Signature
algorithm: sha256
encoding: hex
prefix: "sha256="
replayProtection:
enabled: true
timestampHeader: X-Webhook-Timestamp
maxAge: 5 minutes
nonceHeader: X-Webhook-Nonce
events:
resultPosted:
filter:
field: "event_type"
value: "inspection_result"
mapping:
inspectionId: $payload.inspection_id
result: $payload.result
completedAt: "$parseDate($payload.completed_at, 'yyyy-MM-dd')"
action: update
resource: Inspection
target: "$findOne(Inspection, id == $mapped.inspectionId).id"
input:
rawResult: $mapped.result
completedAt: $mapped.completedAt
documentStorage:
description: "Store generated permit documents in object storage."
type: s3
bucket: "${S3_BUCKET}"
region: "${AWS_REGION}"
auth:
type: aws_sigv4
accessKeyId: "${AWS_ACCESS_KEY_ID}"
secretAccessKey: "${AWS_SECRET_ACCESS_KEY}"
mailServer:
description: "Outbound email for notifications and documents."
type: smtp
host: "${SMTP_HOST}"
port: 587
auth:
type: basic
username: "${SMTP_USER}"
password: "${SMTP_PASS}"
# =========================================================================
# §26 VIEWS
# =========================================================================
views:
activePermits:
description: "Approved permits with fee utilization and days remaining."
source: Permit
filter: "status == 'approved'"
permissions: [clerk, reviewer, admin]
columns:
- { field: permitNumber, label: "Permit #" }
- { field: permitTypeName, label: "Type" }
- { field: totalFeeAssessed, label: "Assessed", format: money }
- { name: collected, label: "Collected", computed: "sum(Payment, 'amount', permitId == $record.id && status == 'paid')", format: money }
- { name: balance, label: "Balance", computed: "totalFeeAssessed - collected", format: money }
- { field: expiresAt, label: "Expires", format: date }
- name: daysLeft
label: "Days Left"
computed: "daysUntil(expiresAt)"
highlight:
red: "< 14"
yellow: "< 60"
green: ">= 60"
sort: daysLeft asc
defaultLimit: 50
# =========================================================================
# §26 REPORTS
# =========================================================================
reports:
complianceQuarterly:
description: "Quarterly permitting and fee-collection summary."
permissions: [admin, financeOfficer]
header:
title: "Quarterly Compliance Report"
subtitle: "Quarter {$params.quarter} — {$params.year}"
parameters:
- { name: year, type: integer, default: "$currentYear", label: "Year" }
- { name: quarter, type: string, default: "$currentQuarter", label: "Quarter" }
- { name: department, type: string, optional: true, label: "Department", enum: "distinct(Permit, 'departmentName')" }
sections:
- name: summary
type: metrics
metrics:
- { label: "Permits Issued", value: "count(Permit, status == 'approved')", format: integer }
- { label: "Fees Assessed", value: "sum(Permit, 'totalFeeAssessed', status == 'approved')", format: money }
- { label: "Fees Collected", value: "sum(Payment, 'amount', status == 'paid')", format: money }
- { label: "Collection Rate", value: "sum(Payment, 'amount', status == 'paid') / sum(Permit, 'totalFeeAssessed', status == 'approved') * 100", format: percentage }
- name: detail
type: table
view: activePermits
groupBy: permitTypeName
totals: [totalFeeAssessed]
emptyMessage: "No active permits for the selected period."
footer:
text: "Generated {$now} by {$actor.name}"
confidentiality: "INTERNAL — Not for public distribution"
# =========================================================================
# §26 DASHBOARDS
# =========================================================================
dashboards:
operationsOverview:
description: "Live permitting operations dashboard."
permissions: [clerk, admin, financeOfficer]
refresh: 5m
layout: { columns: 3 }
widgets:
- name: approvalRate
type: gauge
position: { row: 1, col: 1 }
query:
value: "count(Permit, status == 'approved') / count(Permit, status != 'draft' && status != 'withdrawn') * 100"
label: "Approval Rate"
format: percentage
thresholds: { green: ">= 80", yellow: ">= 60", red: "< 60" }
target: 85
- name: overduePermits
type: counter
position: { row: 1, col: 2 }
query:
value: "count(Permit, status == 'under_review' && daysOld(updatedAt) > 30)"
label: "Stale Reviews"
icon: lucide:alert-triangle
thresholds: { green: "== 0", yellow: "<= 3", red: "> 3" }
- name: feesCollected
type: scorecard
position: { row: 1, col: 3 }
query:
value: "sum(Payment, 'amount', status == 'paid')"
label: "Fees Collected"
format: money
- name: permitsByStatus
type: breakdown
position: { row: 2, col: 1 }
query:
resource: Permit
groupBy: status
metric: count
label: "Permits by Status"
- name: intakeTrend
type: timeSeries
position: { row: 2, col: 2, span: 2 }
query:
resource: Permit
metric: count
groupBy: "$dateGroup(createdAt, 'week')"
filter: "createdAt > dateAdd(now(), -90, 'day')"
label: "Submissions — Last 90 Days"
- name: recentAlerts
type: timeline
position: { row: 3, col: 1, span: 3 }
query:
resource: "*"
history: true
filter: "severity == 'error' || severity == 'warning'"
limit: 10
label: "Recent Alerts"
# =========================================================================
# §7–§20 RESOURCES
# =========================================================================
resources:
# ----------------------------------------------------------------------
# SiteConfig — §17.4 singleton; demonstrates component/svg/font/json/color
# ----------------------------------------------------------------------
SiteConfig:
description: "Agency-wide configuration. Exactly one record (singleton)."
api:
disable_list: true
disable_create: true
disable_delete: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
agencyName: { type: string, required: true, maxLength: 200 }
logoSvg: { type: svg, maxSize: 50kb }
headingFont: { type: font, sources: [google, system], default: "Inter" }
bodyFont: { type: font, sources: [google, system], default: "Source Serif 4" }
primaryColor: { type: color, default: "#1e3a8a" }
branding:
type: component
collapsed: false
object:
tagline: { type: string, maxLength: 200 }
accentColor: { type: color, default: "#f59e0b" }
supportEmail: { $ref: '#/properties/emailAddr' }
featureFlags: { type: json, description: "Arbitrary runtime flags" }
$merge:
- { $ref: '#/objects/Timestamp' }
permissions:
get: ["*"]
update: [admin]
# ----------------------------------------------------------------------
# Department — org unit; timezone; reactive cross-resource computed field
# ----------------------------------------------------------------------
Department:
description: "An agency department that owns permits and employs inspectors."
features:
audit: true
search: true
export: true
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true, unique: true, maxLength: 150 }
slug: { $ref: '#/properties/slugField', from: name }
code: { type: string, required: true, unique: true, uppercase: true, pattern: "^[A-Z]{2,5}$" }
timezone: { type: timezone, default: "America/New_York" }
indirectRate: { type: percentage, min: 0, max: 100, default: 12.5 }
contactEmail: { $ref: '#/properties/emailAddr' }
$merge:
- { $ref: '#/objects/Timestamp' }
computed:
# §13.7 reactive: recomputes across ALL permits in this department
totalAssessedFees:
type: money
formula: "sum(Permit, 'totalFeeAssessed', departmentId == $this.id)"
stored: true
reactive: true
recomputeOn: [Permit.create, Permit.update, Permit.delete]
relationships:
inspectors: { type: hasMany, target: Inspector, foreignKey: departmentId }
permits: { type: hasMany, target: Permit, foreignKey: departmentId }
budgets: { type: hasMany, target: BudgetWorksheet, foreignKey: departmentId }
indexes:
- { fields: [code], unique: true }
- { fields: [name] }
permissions:
list: ["*"]
get: ["*"]
create: [admin]
update: [admin]
delete: [admin]
# ----------------------------------------------------------------------
# PermitType — config catalog; self-referential tree; icon/markdown/money
# ----------------------------------------------------------------------
PermitType:
description: "Catalog of permit/license types, nestable into a category tree."
features:
audit: true
search: true
caching: true
cacheTtlSeconds: 600
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true, maxLength: 200 }
slug: { $ref: '#/properties/slugField', from: name }
parentId: { type: uuid, description: "Parent type for nesting (null = top level)" }
icon: { type: icon, iconSets: [lucide, seal], default: "lucide:file-text" }
color: { type: color, default: "#3b82f6" }
baseFee: { type: money, min: 0, required: true }
defaultRisk: { $ref: '#/enums/RiskLevel', default: low }
processingDays: { type: integer, min: 0, default: 30 }
instructions: { type: markdown, description: "Applicant-facing guidance (markdown)" }
active: { type: boolean, default: true }
sortOrder: { type: integer, default: 0 }
$merge:
- { $ref: '#/objects/Timestamp' }
relationships:
parent: { type: belongsTo, target: PermitType, foreignKey: parentId, onDelete: restrict }
children: { type: hasMany, target: PermitType, foreignKey: parentId }
permits: { type: hasMany, target: Permit, foreignKey: permitTypeId }
indexes:
- { fields: [parentId] }
- { fields: [active, sortOrder] }
permissions:
list: ["*"]
get: ["*"]
create: [admin]
update: [admin]
delete: [admin]
# ----------------------------------------------------------------------
# Applicant — PII-heavy; privacy; password/token/bytes/set/language; owner
# ----------------------------------------------------------------------
Applicant:
description: "A resident, business, or contractor who applies for permits."
features:
softDelete: true
audit: true
versioning: true
search: true
export: true
import: true
validationMode: strict
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
applicantNumber: { type: autoincrement }
type: { $ref: '#/enums/ApplicantType', default: resident }
firstName:
type: string
required: true
maxLength: 100
trim: true
privacy: { classification: personalData, pii: true }
lastName:
type: string
required: true
maxLength: 100
trim: true
privacy: { classification: personalData, pii: true }
email:
$ref: '#/properties/emailAddr'
required: true
unique: true
privacy: { classification: personalData, pii: true, subjectIdentifier: true }
phone: { $ref: '#/properties/phoneNum', privacy: { classification: personalData, pii: true } }
dateOfBirth:
type: date
privacy: { classification: sensitiveData, pii: true }
ssn:
type: string
pattern: "^[0-9]{3}-[0-9]{2}-[0-9]{4}$"
mask: true
privacy: { classification: sensitiveData, pii: true, encrypted: true, subjectIdentifier: true }
preferredLanguage: { type: language, default: "en" }
preferredChannels: { type: set, items: { type: string }, description: "email | sms | mail" }
mailingAddress:
type: component
object: { $ref: '#/objects/Address' }
collapsed: true
portalPin: { type: password, description: "Self-service portal credential (hashed)" }
portalToken: { type: token, description: "Auto-generated portal access token" }
signatureImage: { type: bytes, maxSize: 200000, description: "Captured signature bitmap" }
legacyImportedAt: { type: timestamp, hidden: true, requestable: true }
notes: { type: text, maxLength: 5000 }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
- { $ref: '#/objects/AuditMeta' }
computed:
fullName:
type: string
formula: "concat(firstName, ' ', lastName)"
permitCount:
type: integer
formula: "count(permits)"
stored: true
updateOn: [permits.create, permits.delete]
relationships:
permits: { type: hasMany, target: Permit, foreignKey: applicantId, orderBy: { createdAt: desc } }
validation:
adultIfBusinessOwner:
rule: "type != 'business' || dateOfBirth == null || yearsBetween(dateOfBirth, now()) >= 18"
message: "Business owners must be at least 18."
uniqueSsn:
rule: "ssn == null || !exists('Applicant', { ssn: ssn, id_ne: $this.id })"
message: "An applicant with this SSN already exists."
when: create
indexes:
- { fields: [email], unique: true }
- { fields: [type] }
- { fields: [deletedAt, type], description: "Active applicants by type" }
permissions:
list: [clerk, reviewer, admin]
get: "isOwner() || hasAnyRole('clerk','reviewer','admin')"
create: [authenticated]
update: [owner, clerk, admin]
delete: [admin]
fields:
ssn:
read: [privacyOfficer, admin]
write: [privacyOfficer, admin]
mask: true
notes:
read: [clerk, reviewer, admin]
write: [clerk, admin]
legacyImportedAt:
write: [admin]
# ----------------------------------------------------------------------
# Inspector — staff; certification expiry feeds a relative schedule
# ----------------------------------------------------------------------
Inspector:
description: "Field inspector employed by a department."
features:
audit: true
search: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
name: { type: string, required: true, maxLength: 200 }
email: { $ref: '#/properties/emailAddr', required: true, unique: true }
departmentId: { type: uuid, required: true, immutable: true }
certification: { type: string, maxLength: 120 }
certExpiresAt: { type: date }
active: { type: boolean, default: true }
senior: { type: boolean, default: false }
$merge:
- { $ref: '#/objects/Timestamp' }
relationships:
department: { type: belongsTo, target: Department, foreignKey: departmentId, onDelete: restrict, required: true }
inspections: { type: hasMany, target: Inspection, foreignKey: inspectorId }
indexes:
- { fields: [departmentId] }
- { fields: [active] }
permissions:
list: [authenticated]
get: [authenticated]
create: [admin]
update: [admin]
delete: [admin]
# ----------------------------------------------------------------------
# Permit — the core lifecycle resource. State machine, history, computed,
# conditional permissions, scopes, nearly every scalar type.
# ----------------------------------------------------------------------
Permit:
description: >
A permit/license application and its full lifecycle. The heart of the
domain — exercises the state machine, history, rules, workflows,
templates, integrations, computed aggregations, and conditional RBAC.
features:
softDelete: true
audit: true
versioning: true
search: true
export: true
caching: true
cacheTtlSeconds: 120
transactions: true
validationMode: strict
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
caseSequence: { type: autoincrement }
permitNumber: { type: string, required: true, unique: true, immutable: true, pattern: "^PMT-[0-9]{4}-[0-9]{4}$", uppercase: true }
title: { type: string, required: true, maxLength: 250, trim: true }
slug: { $ref: '#/properties/slugField', from: title }
status: { $ref: '#/enums/PermitStatus', default: draft }
riskLevel: { $ref: '#/enums/RiskLevel', default: low }
applicantId: { type: uuid, required: true, immutable: true }
permitTypeId: { type: uuid, required: true }
permitTypeName: { type: string, maxLength: 200, description: "Denormalized for display/reports" }
departmentId: { type: uuid }
departmentName: { type: string, maxLength: 150 }
# money / decimal / percentage
totalFeeAssessed: { type: money, min: 0, default: 0 }
assessedValue: { type: decimal, precision: 12, scale: 2, min: 0 }
completionPercent: { type: percentage, min: 0, max: 100, default: 0 }
lotSizeAcres: { type: float, min: 0 }
# geo (float) + time + date
siteLatitude: { type: float, min: -90, max: 90 }
siteLongitude: { type: float, min: -180, max: 180 }
submittedAt: { type: datetime }
decisionAt: { type: datetime }
decisionReason: { type: text, maxLength: 2000 }
expiresAt: { type: date }
# arrays / set / json / markdown
relatedCaseNumbers: { type: array, items: { type: string }, maxItems: 20, uniqueItems: true }
flags: { type: set, items: { type: string } }
metadata: { type: json }
scopeOfWork: { type: markdown }
# content / media
applicationForm: { type: media, accept: [application/pdf], maxSize: 10mb }
# repeater of line items (component-like array)
feeBreakdown:
type: repeater
sortable: true
minItems: 0
maxItems: 50
object:
description: { type: string, required: true, maxLength: 200 }
category: { type: string, enum: [application, plan_review, inspection, surcharge], default: application }
amount: { type: money, required: true, min: 0 }
# document attach target + external id (set by integration)
certificateDocId: { type: uuid, internal: true }
stateRegistryId: { type: string, maxLength: 80, hidden: true, requestable: true }
stale: { type: boolean, default: false }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
- { $ref: '#/objects/AuditMeta' }
# §13 computed fields: aggregation + materialized dependency chain + virtual
computed:
feesCollected:
type: money
formula: "sum(payments.amount)"
stored: true
updateOn: [payments.create, payments.update, payments.delete]
inspectionCount:
type: integer
formula: "count(inspections)"
stored: true
updateOn: [inspections.create, inspections.delete]
balanceDue:
type: money
formula: "totalFeeAssessed - feesCollected"
stored: true
updateOn: [totalFeeAssessed, feesCollected]
isExpired:
type: boolean
formula: "status != 'approved' ? false : (expiresAt != null && expiresAt < now())"
displayLabel:
type: string
formula: "concat(permitNumber, ' — ', title)"
relationships:
applicant: { type: belongsTo, target: Applicant, foreignKey: applicantId, onDelete: restrict, required: true }
permitType: { type: belongsTo, target: PermitType, foreignKey: permitTypeId, onDelete: restrict, required: true }
department: { type: belongsTo, target: Department, foreignKey: departmentId, onDelete: nullify }
inspections: { type: hasMany, target: Inspection, foreignKey: permitId, orderBy: { createdAt: desc } }
payments: { type: hasMany, target: Payment, foreignKey: permitId }
reviewers: { type: hasMany, target: PermitReviewer, foreignKey: permitId }
tags: { type: belongsToMany, target: PermitTag, through: PermitTagLink, foreignKey: permitId, otherKey: tagId, timestamps: true }
# §12 state machine
stateMachine:
field: status
initial: draft
states:
draft:
description: "Applicant is preparing the application."
# §12 onExit hook — may abort the transition (e.g. final completeness check)
onExit: [validateDraftCompleteness]
transitions:
submit:
to: submitted
guards: [owner, clerk, admin]
requiredFields: [title, applicantId, permitTypeId]
sets:
submittedAt: "now()"
withdraw:
to: withdrawn
guards: [owner, admin]
submitted:
description: "Submitted, awaiting intake."
onEnter: [acknowledgeSubmission]
transitions:
beginReview:
to: under_review
guards: [clerk, reviewer, admin]
withdraw:
to: withdrawn
guards: [owner, admin]
under_review:
description: "Plan examiners reviewing."
transitions:
requestRevision:
to: revision_requested
guards: [reviewer, admin]
requiredFields: [decisionReason]
seniorSignoff:
to: inspection_scheduled
guards: [reviewer, admin]
scheduleInspection:
to: inspection_scheduled
guards: [clerk, reviewer, admin]
reject:
to: rejected
guards: [reviewer, admin]
requiredFields: [decisionReason]
sets:
decisionAt: "now()"
revision_requested:
description: "Awaiting applicant revision."
transitions:
resubmit:
to: under_review
guards: [owner, clerk, admin]
inspection_scheduled:
description: "Inspection pending."
transitions:
approve:
to: approved
guards: [reviewer, admin]
sets:
decisionAt: "now()"
completionPercent: 100
reject:
to: rejected
guards: [reviewer, admin]
requiredFields: [decisionReason]
sets:
decisionAt: "now()"
approved:
description: "Issued and active."
onEnter: [pushToSearchIndex]
transitions:
expire:
to: expired
guards: [admin]
withdraw:
to: withdrawn
guards: [admin]
rejected:
description: "Denied — terminal."
final: true
expired:
description: "Lapsed — terminal."
final: true
withdrawn:
description: "Withdrawn — terminal."
final: true
# §21 history of change
history:
events:
created:
on: create
capture: [permitNumber, title, riskLevel]
label: "Permit {permitNumber} created"
icon: lucide:plus-circle
severity: info
submitted:
on: transition
transition: submit
capture: [permitNumber, totalFeeAssessed]
label: "Submitted for review"
icon: lucide:send
severity: info
approved:
on: transition
transition: approve
capture: [permitNumber, expiresAt]
label: "Permit approved"
icon: lucide:badge-check
severity: success
rejected:
on: transition
transition: reject
capture: [decisionReason]
label: "Permit rejected"
detail: "{decisionReason}"
icon: lucide:x-circle
severity: error
riskChanged:
on: fieldChange
field: riskLevel
capture: [riskLevel]
label: "Risk level changed to {riskLevel}"
icon: lucide:flag
severity: warning
feeOverdue:
on: fieldChange
field: status
condition: "status == 'approved' && balanceDue > 0"
capture: [balanceDue]
label: "Approved with outstanding balance {balanceDue}"
icon: lucide:alert-octagon
severity: error
internalNote:
on: fieldChange
field: decisionReason
capture: [decisionReason]
label: "Internal decision note updated"
roles: [reviewer, admin]
icon: lucide:lock
severity: info
display:
endpoint: true
defaultLimit: 50
maxLimit: 200
order: desc
includeActor: true
groupBy: date
retention:
days: 730
# §14 cross-field + cross-resource validation
validation:
decisionNeedsReason:
rule: "status != 'rejected' || (decisionReason != null && len(decisionReason) >= 10)"
message: "A rejection requires a decision reason of at least 10 characters."
expiryAfterSubmit:
rule: "expiresAt == null || submittedAt == null || expiresAt > submittedAt"
message: "Expiry must be after the submission date."
geoPaired:
rule: "(siteLatitude == null) == (siteLongitude == null)"
message: "Provide both latitude and longitude, or neither."
applicantActive:
rule: "query('Applicant', { id: applicantId })[0].deletedAt == null"
message: "The applicant record must be active."
when: create
# §20 scopes
scopes:
open:
filter: "status NOT IN ('approved','rejected','expired','withdrawn') AND deletedAt IS NULL"
default: true
description: "In-flight applications"
issued:
filter: "status = 'approved' AND deletedAt IS NULL"
description: "Active issued permits"
mine:
filter: "createdById = :currentUserId AND deletedAt IS NULL"
description: "Permits created by the current user"
overdueBalance:
filter: "status = 'approved' AND balanceDue > 0"
description: "Issued permits with an outstanding balance"
all:
filter: ""
description: "Every permit regardless of status or deletion"
# §11 conditional permissions + field-level + transitions
permissions:
list: [authenticated]
get: "isOwner() || hasAnyRole('clerk','reviewer','admin')"
create: [authenticated]
update: [owner, clerk, reviewer, admin]
delete: [admin]
conditional:
update:
draft: [owner, clerk, admin]
under_review: [reviewer, admin]
approved: [admin]
rejected: []
expired: []
withdrawn: []
fields:
decisionReason:
read: [reviewer, admin, owner]
write: [reviewer, admin]
totalFeeAssessed:
write: [clerk, financeOfficer, admin]
stateRegistryId:
read: [admin, financeOfficer]
write: [admin]
transitions:
submit: [owner, clerk, admin]
beginReview: [clerk, reviewer, admin]
requestRevision: [reviewer, admin]
resubmit: [owner, clerk, admin]
seniorSignoff: [reviewer, admin]
scheduleInspection: [clerk, reviewer, admin]
approve: [reviewer, admin]
reject: [reviewer, admin]
expire: [admin]
withdraw: [owner, admin]
indexes:
- { fields: [permitNumber], unique: true }
- { fields: [status] }
- { fields: [applicantId] }
- { fields: [permitTypeId] }
- { fields: [departmentId, status], description: "Departmental status filter" }
- { fields: [expiresAt], description: "Renewal/expiry scans" }
- { fields: [deletedAt, status], description: "Active by status" }
# ----------------------------------------------------------------------
# Inspection — child state machine; repeater checklist; webhook target
# ----------------------------------------------------------------------
Inspection:
description: "A field inspection tied to a permit."
features:
audit: true
search: true
export: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
permitId: { type: uuid, required: true, immutable: true }
inspectorId: { type: uuid }
inspectionType: { type: string, enum: [routine, emergency, reinspection, final], default: routine }
status: { $ref: '#/enums/InspectionStatus', default: scheduled }
scheduledDate: { type: date }
scheduledTime: { type: time }
completedAt: { type: date }
score: { type: integer, min: 0, max: 100 }
rawResult: { type: string, maxLength: 120, description: "Raw result code from vendor webhook" }
rawSensorData: { type: json, internal: true }
gpsLat: { type: float }
gpsLng: { type: float }
signatureBlob: { type: bytes, maxSize: 100000 }
checklist:
type: repeater
sortable: true
object:
item: { type: string, required: true, maxLength: 200 }
passed: { type: boolean, default: false }
notes: { type: text }
$merge:
- { $ref: '#/objects/Timestamp' }
relationships:
permit: { type: belongsTo, target: Permit, foreignKey: permitId, onDelete: cascade, required: true }
inspector: { type: belongsTo, target: Inspector, foreignKey: inspectorId, onDelete: nullify }
stateMachine:
field: status
initial: scheduled
states:
scheduled:
transitions:
start: { to: in_progress, guards: [inspector, admin] }
cancel: { to: cancelled, guards: [clerk, admin] }
in_progress:
transitions:
pass:
to: passed
guards: [inspector, admin]
sets: { completedAt: "now()" }
fail:
to: failed
guards: [inspector, admin]
sets: { completedAt: "now()" }
passed: { final: true }
failed:
transitions:
reinspect: { to: scheduled, guards: [clerk, admin] }
cancelled: { final: true }
history:
events:
inspectionCreated:
on: create
capture: [inspectionType, scheduledDate]
label: "Inspection scheduled ({inspectionType})"
icon: lucide:calendar
severity: info
inspectionPassed:
on: transition
transition: pass
label: "Inspection passed"
icon: lucide:check
severity: success
inspectionFailed:
on: transition
transition: fail
capture: [score]
label: "Inspection failed (score {score})"
icon: lucide:x
severity: error
indexes:
- { fields: [permitId], description: "Aggregation trigger for Permit.inspectionCount" }
- { fields: [inspectorId] }
- { fields: [status] }
permissions:
list: [authenticated]
get: [authenticated]
create: [clerk, inspector, admin]
update: [inspector, admin]
delete: [admin]
# ----------------------------------------------------------------------
# Payment — fee payment; state machine; aggregation source; integration
# ----------------------------------------------------------------------
Payment:
description: "A fee payment against a permit."
features:
audit: true
export: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
permitId: { type: uuid, required: true, immutable: true }
amount: { type: money, required: true, min: 0, privacy: { classification: financialData } }
currency: { type: currency, default: "USD" }
method: { type: string, enum: [card, ach, check, cash, waiver], default: card }
status: { $ref: '#/enums/PaymentStatus', default: pending }
gatewayReference: { type: string, maxLength: 120, hidden: true, requestable: true }
receivedAt: { type: datetime }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/AuditMeta' }
relationships:
permit: { type: belongsTo, target: Permit, foreignKey: permitId, onDelete: cascade, required: true }
stateMachine:
field: status
initial: pending
states:
pending:
transitions:
markPaid:
to: paid
guards: [financeOfficer, clerk, admin]
sets: { receivedAt: "now()" }
markFailed: { to: failed, guards: [financeOfficer, admin] }
paid:
transitions:
refund: { to: refunded, guards: [financeOfficer, admin] }
failed:
transitions:
retry: { to: pending, guards: [financeOfficer, clerk, admin] }
refunded: { final: true }
history:
events:
paymentReceived:
on: transition
transition: markPaid
capture: [amount]
label: "Payment of {amount} received"
icon: lucide:dollar-sign
severity: success
paymentRefunded:
on: transition
transition: refund
capture: [amount]
label: "Payment of {amount} refunded"
icon: lucide:rotate-ccw
severity: warning
indexes:
- { fields: [permitId], description: "Aggregation trigger for Permit.feesCollected" }
- { fields: [status] }
permissions:
list: [financeOfficer, clerk, admin]
get: "isOwner() || hasAnyRole('financeOfficer','clerk','admin')"
create: [financeOfficer, clerk, admin]
update: [financeOfficer, admin]
delete: [admin]
# ----------------------------------------------------------------------
# PermitTag — belongsToMany target (junction PermitTagLink auto-table)
# ----------------------------------------------------------------------
PermitTag:
description: "Free-form tags applied to permits (many-to-many)."
features:
search: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
name: { type: string, required: true, unique: true, maxLength: 60, lowercase: true }
color: { type: color, default: "#64748b" }
$merge:
- { $ref: '#/objects/Timestamp' }
relationships:
permits: { type: belongsToMany, target: Permit, through: PermitTagLink, foreignKey: tagId, otherKey: permitId, timestamps: true }
permissions:
list: ["*"]
get: ["*"]
create: [clerk, admin]
update: [admin]
delete: [admin]
# ----------------------------------------------------------------------
# PermitReviewer — §8.7 composite primary key junction with extra data
# ----------------------------------------------------------------------
PermitReviewer:
description: "Assignment of a reviewer to a permit (composite-key resource)."
object:
permitId: { type: uuid, required: true }
inspectorId: { type: uuid, required: true }
assignedAt: { type: datetime, autoNow: create }
senior: { type: boolean, default: false }
role: { type: string, enum: [plan_examiner, fire, zoning, senior], default: plan_examiner }
compositePrimaryKey: [permitId, inspectorId]
relationships:
permit: { type: belongsTo, target: Permit, foreignKey: permitId, onDelete: cascade }
inspector: { type: belongsTo, target: Inspector, foreignKey: inspectorId, onDelete: restrict }
indexes:
- { fields: [permitId] }
- { fields: [inspectorId] }
permissions:
list: [clerk, reviewer, admin]
get: [clerk, reviewer, admin]
create: [clerk, admin]
update: [admin]
delete: [admin]
# ----------------------------------------------------------------------
# PublicNotice — content showcase: blocks/media/svg/icon/markdown; custom op
# ----------------------------------------------------------------------
PublicNotice:
description: "Public-facing notices and bulletins (CMS-style content)."
features:
softDelete: true
audit: true
search: true
api:
customOperations:
- name: publish
method: POST
path: /:id/publish
permissions: [clerk, admin]
fields:
published: true
publishedAt: "NOW()"
- name: archive
method: POST
path: /:id/archive
permissions: [admin]
fields:
published: false
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
headline: { type: string, required: true, maxLength: 200 }
slug: { $ref: '#/properties/slugField', from: headline }
severity: { $ref: '#/enums/NoticeSeverity', default: info }
permitTypeId: { type: uuid, description: "Optional related permit type" }
summary: { type: markdown }
body:
type: blocks
maxBlocks: 100
allowedBlocks:
- paragraph
- heading
- list
- image
- $ref: '#/blocks/Callout'
- $ref: '#/blocks/NoticeBanner'
featuredImage: { type: media, accept: [image/jpeg, image/png, image/webp], maxSize: 5mb, aspectRatio: "16:9" }
sealSvg: { type: svg, maxSize: 50kb }
bannerIcon: { type: icon, iconSets: [lucide, seal] }
attachments:
type: repeater
object:
label: { type: string, required: true, maxLength: 120 }
file: { type: media, accept: [application/pdf, image/*], maxSize: 10mb }
published: { type: boolean, default: false }
publishedAt: { type: datetime }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
relationships:
permitType: { type: belongsTo, target: PermitType, foreignKey: permitTypeId, onDelete: nullify }
scopes:
live:
filter: "published = 1 AND deletedAt IS NULL"
default: true
drafts:
filter: "published = 0 AND deletedAt IS NULL"
all:
filter: ""
indexes:
- { fields: [published, publishedAt] }
- { fields: [severity] }
permissions:
list: ["*"]
get: ["*"]
create: [clerk, admin]
update: [clerk, admin]
delete: [admin]
# ----------------------------------------------------------------------
# BudgetWorksheet — §28 sheet mode: dynamic columns + reactive computed
# ----------------------------------------------------------------------
BudgetWorksheet:
description: "Departmental budget worksheet (spreadsheet/sheet-mode resource)."
features:
audit: true
dynamicFields:
enabled: true
allowTypes: [string, money, integer, decimal, float, date, boolean, text]
maxColumns: 50
permissions:
addColumn: [admin, financeOfficer]
renameColumn: [admin, financeOfficer]
deleteColumn: [admin]
computedFields:
reactive: true
overridable: true
crossRecord: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
departmentId: { type: uuid, required: true, immutable: true }
fiscalYear: { type: integer, required: true, min: 2000, max: 2100 }
name: { type: string, required: true, maxLength: 150 }
$merge:
- { $ref: '#/objects/Timestamp' }
computed:
grandTotal:
type: money
formula: "sum(lines.total)"
stored: true
updateOn: [lines.create, lines.update, lines.delete]
relationships:
department: { type: belongsTo, target: Department, foreignKey: departmentId, onDelete: cascade, required: true }
lines: { type: hasMany, target: BudgetLine, foreignKey: worksheetId }
indexes:
- { fields: [departmentId, fiscalYear], unique: true }
permissions:
list: [financeOfficer, admin]
get: [financeOfficer, admin]
create: [financeOfficer, admin]
update: [financeOfficer, admin]
delete: [admin]
# ----------------------------------------------------------------------
# BudgetLine — sheet-mode rows with reactive same-record formula chain
# ----------------------------------------------------------------------
BudgetLine:
description: "A budget line item with quarterly columns and a reactive total."
features:
audit: true
computedFields:
reactive: true
overridable: true
object:
id: { type: uuid, primaryKey: true, allowUserProvided: true }
worksheetId: { type: uuid, required: true, immutable: true }
category: { type: string, enum: [personnel, equipment, supplies, services, other], required: true }
lineItem: { type: string, required: true, maxLength: 200 }
q1: { type: money, default: 0 }
q2: { type: money, default: 0 }
q3: { type: money, default: 0 }
q4: { type: money, default: 0 }
$merge:
- { $ref: '#/objects/Timestamp' }
computed:
total:
type: money
formula: "q1 + q2 + q3 + q4"
stored: true
reactive: true
updateOn: [q1, q2, q3, q4]
# reactive cross-record SUMIF: total spend for this category across the worksheet
categoryTotal:
type: money
formula: "sum(BudgetLine, 'total', worksheetId == $this.worksheetId && category == $this.category)"
stored: true
reactive: true
recomputeOn: [BudgetLine.create, BudgetLine.update, BudgetLine.delete]
relationships:
worksheet: { type: belongsTo, target: BudgetWorksheet, foreignKey: worksheetId, onDelete: cascade, required: true }
history:
events:
cellEdited:
on: fieldChange
field: "*"
label: "{fieldName} changed"
severity: info
columnAdded:
on: schemaChange
change: addColumn
capture: [columnName, columnType]
label: "Column '{columnName}' added"
severity: info
indexes:
- { fields: [worksheetId] }
- { fields: [worksheetId, category] }
permissions:
list: [financeOfficer, admin]
get: [financeOfficer, admin]
create: [financeOfficer, admin]
update: [financeOfficer, admin]
delete: [admin]