# ============================================================================
#  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]
