CanLang

A programming language for business apps

Build software around your business.

You and your AI define your data, permissions and workflows. CanLang provides the business foundations.

Install CanLang

curl -fsSL https://sodaos.dev/CanLang/install.sh | bash

macOS Apple Silicon · Linux x64 (glibc 2.39+)
One command selects your platform. Runtime included.

Built to cover 80% of standard business use cases: requests, tasks, approvals, bookings, invoicing and reporting.

Read the language

See how the language fits together.

This complete equipment-request example declares who can see a request, who can approve it, and the form and list that use those rules.

EquipmentRequests.can

# Request equipment and let a reviewer approve it.
app EquipmentRequests
Given
 role reviewer
 Request { item:text trim, requested_by:user server=actor, status:enum(requested,approved)=requested }
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 lock Request fields=item,requested_by when=row.status==approved
When
 crud Request by=members fields=item when=row.requested_by==actor and row.status==requested delete=none
 # Approve another teammate's equipment request.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  do set request {status=approved}
Then
 page / title="Equipment requests"
  form Request.create
  list Request
   text row.item,row.status
   action approve

Complete illustrative application. The declared permissions and business rules stay explicit.

Given

Describe the records and who can see them.

An equipment request keeps its item, requester and status together. Shared behavior handles standard storage and team ownership. Explicit read policies let members see their own requests and reviewers see the requests they review.

When

Write the rules that make the process yours.

Enable the standard record operations and declare the approval rule: only a reviewer can approve, and never their own request. Permissions, checks and locks stay explicit in the source; defaults do not decide who has authority.

Then

Connect the interface to the same behavior.

A form, a list and an approval action refer to those records and operations. CanLang supplies their standard interface behavior, with the same declared permissions and business rules behind every action.

Try these rules in the interactive examples →

From requirements to a working app

Work with your AI. Keep the rules explicit.

Use your preferred editor and AI coding tools. CanLang runs independently of Soda OS, with the runtime included in the standalone installation.

Run your first app →How source ownership works →

Illustrative authoring cycle

  1. Describe the work.

    Business requirement
    “A reviewer can approve another person’s request.”
    WhoWhich recordWhich action

    Tell your AI what records you need, who can access them and how work moves forward. Include a permitted action, a denied action and what should happen when a record has changed.

  2. Read the source it writes.

    Your application.can
    Given
    Records + access
    When
    Operations + rules
    Then
    Connected interfaces

    Your application lives in .can files in your own project. Review the data, permissions and workflow decisions your agent declares, and keep the source in version control.

  3. Check it and try it.

    AllowAnother person approves
    RefuseRequester approves their own

    Run the compiler checks, open a local preview and test the workflow with different users. Check both what people can do and which actions must be refused.

  4. Change a rule. Check again.

    Revised source
    Changed ruleFresh checks

    Try the new path.
    Check what must keep working.

    Edit the source yourself or ask your AI to revise it. Rerun the checks and inspect the updated application as your requirements change.

Describe a requirement, inspect the source, verify behavior and keep improving the same application.

The next business requirement starts another pass through the same source.

How CanLang fits with Soda OS

On your own computer.

Install the standalone toolchain, use your preferred editor and AI coding tools, and develop your application locally. CanLang works independently of Soda OS.

Inside your software factory.

Soda OS coordinates requirements, coding, review and integration. CanLang gives that work a shared language for your business application.

Department examples

See what the language builds.

Four department workflows, expressed in CanLang. Read the source and try the permissions and actions it describes.

Operations · Illustrative application

Equipment requests

Collect equipment requests and give reviewers a clear approval step. Keep each request’s item, requester and status together as the process evolves.

Read the full equipment requests story →

Explore a larger application

Read the CanLang

EquipmentRequests.can
Store the request. Define who can see it.

The item, requester and status belong to one record. Members see their own requests; reviewers can see the requests they need to review.

EquipmentRequests.canExcerpt
Given
 role reviewer
 Request { item:text trim, requested_by:user server=actor, status:enum(requested,approved)=requested }
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 lock Request fields=item,requested_by when=row.status==approved
Equipment requestsIllustration
Item
Standing desk
Requested by
Avery
Status
Requested

Team memberSees their own requests.

ReviewerCan read requests for review.

Make the approval rule part of the application.

Only a reviewer can approve, and never their own request. Approval updates the record; the approved item and requester are locked against edits.

EquipmentRequests.canExcerpt
When
 crud Request by=members fields=item when=row.requested_by==actor and row.status==requested delete=none
 # Approve another teammate's equipment request.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  do set request {status=approved}
Equipment requestsIllustration
RequestedApproved
  • Reviewer, someone else’s requestApproval is allowed.
  • Approving your own requestBlocked by the business rule.
  • Approving an approved requestBlocked by the same rule.
Connect the screen to the same records and rules.

A request form, a list and an approval action refer to the existing record and operation. The interface follows the application’s authority rules.

EquipmentRequests.canExcerpt
Then
 page / title="Equipment requests"
  form Request.create
  list Request
   text row.item,row.status
   action approve
View the complete equipment requests source
# Request equipment and let a reviewer approve it.
app EquipmentRequests
Given
 role reviewer
 Request { item:text trim, requested_by:user server=actor, status:enum(requested,approved)=requested }
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 lock Request fields=item,requested_by when=row.status==approved
When
 crud Request by=members fields=item when=row.requested_by==actor and row.status==requested delete=none
 # Approve another teammate's equipment request.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  do set request {status=approved}
Then
 page / title="Equipment requests"
  form Request.create
  list Request
   text row.item,row.status
   action approve

See the rules in action

Interactive example
View CanLangBack to app
EquipmentRequests.canSource

Equipment requests · compact interactive example

# Request equipment and let a reviewer approve it.
app EquipmentRequests
Given
 role reviewer
 Request { item:text trim, requested_by:user server=actor, status:enum(requested,approved)=requested }
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 lock Request fields=item,requested_by when=row.status==approved
When
 crud Request by=members fields=item when=row.requested_by==actor and row.status==requested delete=none
 # Approve another teammate's equipment request.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  do set request {status=approved}
Then
 page / title="Equipment requests"
  form Request.create
  list Request
   text row.item,row.status
   action approve

Interactive example

Equipment requestsExample app
Request equipment

Change roles, then try an action.

Requests
  • Standing deskRequested
  • MonitorApproved

Interactive illustration of the source’s rules. Example data stays in this page.

First version · Expanded illustrative source

From equipment request to delivery

An illustrative equipment workflow with a shared catalogue, separate approval and delivery roles, and a recorded asset handover.

Additional workflows in illustrative source. The interactive demo above covers the smaller example.

Related business records

Each request references catalogue equipment. Members see their requests; reviewers and coordinators can read the queue.

Two accountable handovers

Reviewers approve someone else’s request. Coordinators record delivery and an asset tag after approval; locks preserve the recorded decisions.

Purposeful screens

Request and catalogue pages reuse those records, with filters, workflow actions and expandable history.

Show full sourceHide full sourceEquipmentRequests.can · 74 lines
# Request equipment, review it and record its delivery.
app EquipmentRequests
Given
 role reviewer
 role coordinator
 Equipment {
  name:text trim max=120 label="Equipment",
  category:enum(computer,furniture,accessory) label="Category",
  active:bool=true label="Available to request"
 }
 Request {
  equipment:Equipment label="Equipment",
  reason:text trim max=500 label="Business reason",
  requested_by:user server=actor label="Requested by",
  status:enum(requested,approved,delivered)=requested label="Status",
  approved_by:user? label="Approved by",
  approved_at:datetime? label="Approved at",
  delivered_at:datetime? label="Delivered at",
  asset_tag:text? label="Asset tag"
 }
 policy Equipment read=members
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 policy Request read=coordinator
 invariant Request: trim(row.reason)!=""
 lock Request fields=equipment,reason,requested_by when=row.status!=requested
 lock Request fields=approved_by,approved_at when=row.status!=requested
 lock Request fields=status,delivered_at,asset_tag when=row.status==delivered
When
 crud Equipment by=coordinator fields=name,category,active delete=none
 crud Request by=members fields=equipment,reason when=row.requested_by==actor and row.status==requested delete=none
 # Review another teammate's request against the equipment catalogue.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  require request.equipment.active
  do set request {status=approved,approved_by=actor,approved_at=now}
 # Record the physical asset when approved equipment reaches the requester.
 scenario deliver(request:Request,asset_tag:text) by=coordinator
  require request.status==approved and trim(asset_tag)!=""
  do set request {status=delivered,asset_tag=trim(asset_tag),delivered_at=now}
Then
 preferences { status:Request.status=requested }
 page / title="Equipment requests"
  breadcrumbs
  card "Request equipment"
   form Request.create
    input equipment
    textarea reason
  card "Requests and delivery"
   tabs preferences.status
   list Request as request where request.status==preferences.status order=-created empty="No equipment requests"
    badge row.status
    text row.equipment.name,row.reason,row.requested_by
    edit
    action approve
    action deliver
    collapse "Approval and delivery details"
     text row.approved_by,row.approved_at,row.asset_tag,row.delivered_at
     history
    pagination
 page /catalogue title="Equipment catalogue"
  breadcrumbs
  card "Maintain requestable equipment"
   form Equipment.create
    input name
    input category
    input active
  card "Equipment catalogue"
   list Equipment search=name order=name empty="No equipment listed"
    text row.name
    badge row.category
    badge row.active
    edit
    pagination
A later change · Revised illustrative source

Add Finance approval above €1,000

The revised equipment workflow stores its approval policy and EUR amount, then requires a third-person Finance decision above the company’s threshold.

Checked source illustration. The selected v0.1.1 runtime cannot run the required path; runtime qualification and an existing-data transition are still needed.

An exact boundary

At or below €1,000, ordinary approval is sufficient. Above it, delivery waits for Finance.

Independent decisions

Finance differs from the requester and first reviewer. Approval freezes the amount; completed decisions stay protected.

A visible policy transition

New requests select the Finance policy. Earlier requests retain the original path through an explicit data migration.

Show full sourceHide full sourceEquipmentRequestsFinance.can · 128 lines
# Revised application: a company-selected EUR 1,000 Finance threshold.
# Before applying to an existing app, migrate earlier requests to policy=original and leave an unknown historical amount empty.
# Preserve their original decisions; new requests receive the finance policy.
app EquipmentRequests
Given
 role reviewer
 role coordinator
 role finance
 Equipment {
  name:text trim max=120 label="Equipment",
  category:enum(computer,furniture,accessory) label="Category",
  active:bool=true label="Available to request"
 }
 Request {
  equipment:Equipment label="Equipment",
  reason:text trim max=500 label="Business reason",
  amount:money? label="Total in EUR",
  policy:enum(original,finance_threshold) server=finance_threshold label="Approval policy",
  finance_by:user? label="Finance approver",
  finance_at:datetime? label="Finance approved at",
  requested_by:user server=actor label="Requested by",
  status:enum(requested,awaiting_finance,approved,delivered)=requested label="Status",
  approved_by:user? label="Approved by",
  approved_at:datetime? label="Approved at",
  delivered_at:datetime? label="Delivered at",
  asset_tag:text? label="Asset tag"
 }
 policy Equipment read=members
 policy Request read=members where=row.requested_by==actor
 policy Request read=reviewer
 policy Request read=coordinator
 policy Request read=finance where=row.policy==finance_threshold and row.amount!=null and row.amount.minor>100000
 invariant Request: row.policy==original or (row.amount!=null and row.amount.minor>0 and row.amount.currency=="EUR")
 lock Request fields=policy
 lock Request fields=finance_by,finance_at when=row.status!=awaiting_finance
 invariant Request: trim(row.reason)!=""
 lock Request fields=equipment,reason,amount,requested_by when=row.status!=requested
 lock Request fields=approved_by,approved_at when=row.status!=requested
 lock Request fields=status,delivered_at,asset_tag when=row.status==delivered
 fixture claimant=user {roles=[finance]}
 fixture first_reviewer=user {roles=[reviewer,finance]}
 fixture finance_reviewer=user {roles=[finance]}
 fixture desk=Equipment {name="Standing desk",category=furniture}
 fixture pending=Request {equipment=desk,reason="Adjustable working height",amount=money(450,"EUR"),requested_by=claimant}
When
 crud Equipment by=coordinator fields=name,category,active delete=none
 crud Request by=members fields=equipment,reason,amount when=row.requested_by==actor and row.status==requested delete=none
 # Review another teammate's request against the equipment catalogue.
 scenario approve(request:Request) by=reviewer
  require request.status==requested and request.requested_by!=actor
  require request.equipment.active
  do
   if request.policy==finance_threshold and request.amount!=null and request.amount.minor>100000
    set request {status=awaiting_finance,approved_by=actor,approved_at=now}
   else
    set request {status=approved,approved_by=actor,approved_at=now}
  examples request=pending
   as,request.amount,request.policy -> request.status
   first_reviewer,money(450,"EUR"),finance_threshold -> approved
   first_reviewer,money(1000,"EUR"),finance_threshold -> approved
   first_reviewer,money(1400,"EUR"),finance_threshold -> awaiting_finance
   first_reviewer,money(1400,"EUR"),original -> approved
   first_reviewer,null,original -> approved
   members,money(450,"EUR"),finance_threshold -> error(forbidden)
  examples request=pending
   as,request.requested_by,request.status -> request.status
   first_reviewer,first_reviewer,requested -> error(rule_failed)
   first_reviewer,claimant,approved -> error(rule_failed)
 # Finance must be a third person, even when one person has multiple roles.
 scenario approve_finance(request:Request) by=finance
  require request.status==awaiting_finance and request.policy==finance_threshold
  require request.requested_by!=actor and request.approved_by!=actor
  do set request {status=approved,finance_by=actor,finance_at=now}
  examples request=pending
   as,request.status,request.amount,request.approved_by -> request.status,request.finance_by
   finance_reviewer,awaiting_finance,money(1400,"EUR"),first_reviewer -> approved,finance_reviewer
   first_reviewer,awaiting_finance,money(1400,"EUR"),first_reviewer -> error(rule_failed)
   claimant,awaiting_finance,money(1400,"EUR"),first_reviewer -> error(rule_failed)
   finance_reviewer,approved,money(1400,"EUR"),first_reviewer -> error(rule_failed)
 # Record the physical asset when approved equipment reaches the requester.
 scenario deliver(request:Request,asset_tag:text) by=coordinator
  require request.status==approved and trim(asset_tag)!=""
  do set request {status=delivered,asset_tag=trim(asset_tag),delivered_at=now}
  examples request=pending
   as,request.status,asset_tag -> request.status,request.asset_tag
   coordinator,approved,"DESK-104" -> delivered,"DESK-104"
   coordinator,awaiting_finance,"DESK-104" -> error(rule_failed)
   coordinator,approved," " -> error(rule_failed)
   members,approved,"DESK-104" -> error(forbidden)
   coordinator,delivered,"DESK-104" -> error(rule_failed)
Then
 preferences { status:Request.status=requested }
 page / title="Equipment requests"
  breadcrumbs
  card "Request equipment"
   form Request.create
    input equipment
    textarea reason
    input amount
  card "Requests and delivery"
   tabs preferences.status
   list Request as request where request.status==preferences.status order=-created empty="No equipment requests"
    badge row.status
    text row.equipment.name,row.reason,row.amount,row.requested_by
    badge row.policy
    edit
    action approve
    action approve_finance
    action deliver
    collapse "Approval and delivery details"
     text row.approved_by,row.approved_at,row.finance_by,row.finance_at
     text row.asset_tag,row.delivered_at
     history
    pagination
 page /catalogue title="Equipment catalogue"
  breadcrumbs
  card "Maintain requestable equipment"
   form Equipment.create
    input name
    input category
    input active
  card "Equipment catalogue"
   list Equipment search=name order=name empty="No equipment listed"
    text row.name
    badge row.category
    badge row.active
    edit
    pagination
HR · Illustrative application

Onboarding tasks

Give each onboarding step a responsible person and a clear completion state. HR follows the full list; teammates keep track of their assigned work.

Read the full employee onboarding story →

Explore a larger application

Read the CanLang

Onboarding.can
Give every onboarding task an owner.

A task stores its title, responsible person and completion state. Teammates see their assigned tasks; HR can read the full list.

Onboarding.canExcerpt
Given
 role hr
 Task { title:text trim, responsible:user, done:bool=false }
 policy Task read=members where=row.responsible==actor
 policy Task read=hr
 lock Task fields=title,responsible when=row.done
Onboarding tasksIllustration
Task
Read the safety handbook
Responsible
Sam
Status
Incomplete

Assigned teammateSees tasks assigned to them.

HRReads the full onboarding task list.

Let the responsible person complete the task.

HR creates and updates unfinished tasks. Only the assigned teammate can mark one complete; its title and responsible person are then locked against edits.

Onboarding.canExcerpt
When
 crud Task by=hr fields=title,responsible when=not row.done delete=none
 # Mark your assigned onboarding task complete.
 scenario complete(task:Task) by=members
  require task.responsible==actor and not task.done
  do set task {done=true}
Onboarding tasksIllustration
IncompleteComplete
  • Sam completes Sam’s taskThe assigned teammate can complete it.
  • Another teammate completes Sam’s taskBlocked by the assignment rule.
  • Changing a completed task’s ownerThe responsible person and title are locked.
Put assignment and progress in the same tool.

HR assigns work through a form and can edit unfinished tasks. Teammates see their own tasks and complete them from the same list.

Onboarding.canExcerpt
Then
 page / title="Onboarding tasks"
  form Task.create
  list Task
   text row.title,row.responsible
   badge row.done
   edit
   action complete
View the complete onboarding tasks source
# Assign onboarding tasks and let each responsible teammate complete them.
app Onboarding
Given
 role hr
 Task { title:text trim, responsible:user, done:bool=false }
 policy Task read=members where=row.responsible==actor
 policy Task read=hr
 lock Task fields=title,responsible when=row.done
When
 crud Task by=hr fields=title,responsible when=not row.done delete=none
 # Mark your assigned onboarding task complete.
 scenario complete(task:Task) by=members
  require task.responsible==actor and not task.done
  do set task {done=true}
Then
 page / title="Onboarding tasks"
  form Task.create
  list Task
   text row.title,row.responsible
   badge row.done
   edit
   action complete

See the rules in action

Interactive example
View CanLangBack to app
Onboarding.canSource

Onboarding tasks · compact interactive example

# Assign onboarding tasks and let each responsible teammate complete them.
app Onboarding
Given
 role hr
 Task { title:text trim, responsible:user, done:bool=false }
 policy Task read=members where=row.responsible==actor
 policy Task read=hr
 lock Task fields=title,responsible when=row.done
When
 crud Task by=hr fields=title,responsible when=not row.done delete=none
 # Mark your assigned onboarding task complete.
 scenario complete(task:Task) by=members
  require task.responsible==actor and not task.done
  do set task {done=true}
Then
 page / title="Onboarding tasks"
  form Task.create
  list Task
   text row.title,row.responsible
   badge row.done
   edit
   action complete

Interactive example

Onboarding tasksExample app
Assign an onboarding task · HR

Change roles, then try an action.

Tasks
  • Read the safety handbook · SamIncomplete
  • Confirm emergency contacts · SamComplete

Interactive illustration of the source’s rules. Example data stays in this page.

First version · Expanded illustrative source

Coordinate a new starter’s onboarding

An illustrative onboarding workflow connecting new starters, assigned tasks, due dates and completion records across two screens.

Additional workflows in illustrative source. The interactive demo above covers the smaller example.

Tasks linked to people

Every task references a new starter. HR sees the full list; assigned teammates can read their tasks and the relevant starter.

Assignment and completion

HR can reassign unfinished work. The responsible teammate records completion, a timestamp and an optional note; completed tasks are locked.

A shared view of progress

Assignment forms, due-date ordering, completion filters and history connect the task queue to the starter register.

Show full sourceHide full sourceOnboarding.can · 73 lines
# Assign onboarding work and follow each new starter's progress.
app Onboarding
Given
 role hr
 Starter {
  name:text trim max=120 label="New starter",
  employee:user label="Employee account",
  start_date:date label="Start date",
  department:text trim max=120 label="Department"
 }
 Task {
  starter:Starter label="New starter",
  title:text trim max=200 label="Task",
  responsible:user label="Responsible teammate",
  due_date:date label="Due date",
  done:bool=false label="Complete",
  completed_at:datetime? label="Completed at",
  completion_note:text? label="Completion note"
 }
 policy Starter read=members where=row.employee==actor
 policy Starter read=members where=count(Task as task where task.starter==row and task.responsible==actor)>0
 policy Starter read=hr
 policy Task read=members where=row.responsible==actor
 policy Task read=hr
 invariant Task: trim(row.title)!=""
 lock Task fields=starter,title,responsible,due_date when=row.done
 lock Task fields=done,completed_at,completion_note when=row.done
When
 crud Starter by=hr fields=name,employee,start_date,department delete=none
 crud Task by=hr fields=starter,title,responsible,due_date when=not row.done delete=none
 # The teammate responsible for a task records its completion.
 scenario complete(task:Task,note:text?) by=members
  require task.responsible==actor and not task.done
  do set task {done=true,completed_at=now,completion_note=note}
 # HR can hand unfinished work to another teammate.
 scenario reassign(task:Task,responsible:user) by=hr
  require not task.done
  do set task {responsible}
Then
 preferences { view:enum(unfinished,finished)=unfinished }
 page / title="Onboarding tasks"
  breadcrumbs
  card "Assign onboarding work"
   form Task.create
    input starter
    input title
    input responsible
    input due_date
  card "Assigned tasks and progress"
   tabs preferences.view
   list Task as task where (preferences.view==unfinished and not task.done) or (preferences.view==finished and task.done) order=due_date search=title empty="No onboarding tasks"
    text row.starter.name,row.title,row.responsible,row.due_date
    badge row.done
    edit
    action complete
    action reassign
    collapse "Completion record"
     text row.completed_at,row.completion_note
     history
    pagination
 page /starters title="New starters"
  breadcrumbs
  card "Register a new starter"
   form Starter.create
    input name
    input employee
    input start_date
    input department
  card "New starters"
   list Starter order=start_date search=name empty="No new starters"
    text row.name,row.department,row.start_date
    edit
    pagination
A later change · Revised illustrative source

Preview a new start date before deadlines move

The revised onboarding workflow retains fixed and completed dates while HR previews, confirms or cancels a calendar-day schedule change.

Checked source illustration. The selected v0.1.1 runtime cannot run the required path; runtime qualification and an existing-data transition are still needed.

Deliberate conversion

Existing tasks remain fixed. HR explicitly chooses relative timing for unfinished work and checks the proposed date.

One confirmed schedule

A saved preview records before/after dates, fixed work and completion state. Confirmation rechecks the task set before applying eligible dates together.

Past dates stay visible

Already overdue and proposed past dates require explicit confirmation. This example uses Europe/Brussels for today and calendar-day offsets.

Show full sourceHide full sourceOnboardingSchedule.can · 152 lines
# Revised application: preview and confirm calendar-day scheduling.
# Existing explicit dates are fixed until HR deliberately converts unfinished work.
# The 500-task bound refuses oversized changes; it never truncates the schedule.
app Onboarding
Given
 role hr
 Starter {
  name:text trim max=120 label="New starter",
  employee:user label="Employee account",
  start_date:date label="Start date",
  department:text trim max=120 label="Department"
 }
 Task {
  starter:Starter label="New starter",
  title:text trim max=200 label="Task",
  responsible:user label="Responsible teammate",
  due_date:date label="Due date",
  timing:enum(fixed,relative)=fixed label="Date choice",
  offset_days:int=0 label="Calendar days from start",
  done:bool=false label="Complete",
  completed_at:datetime? label="Completed at",
  completion_note:text? label="Completion note"
 }
 SchedulePreview {
  starter:Starter,
  previous_date:date,
  proposed_date:date,
  prepared_by:user server=actor,
  prepared_at:datetime server=now,
  status:enum(prepared,confirmed,cancelled)=prepared
 }
 DeadlinePreview in SchedulePreview {
  task:Task,
  previous_date:date,
  proposed_date:date,
  was_done:bool,
  timing:Task.timing,
  offset_days:int,
  already_overdue:bool,
  becomes_past_due:bool
 }
 policy SchedulePreview read=hr
 policy DeadlinePreview read=hr
 lock SchedulePreview fields=starter,previous_date,proposed_date,prepared_by,prepared_at
 lock SchedulePreview fields=status when=row.status!=prepared
 lock DeadlinePreview fields=task,previous_date,proposed_date,was_done,timing,offset_days,already_overdue,becomes_past_due
 policy Starter read=members where=row.employee==actor
 policy Starter read=members where=count(Task as task where task.starter==row and task.responsible==actor)>0
 policy Starter read=hr
 policy Task read=members where=row.responsible==actor
 policy Task read=hr
 invariant Task: trim(row.title)!=""
 lock Task fields=starter,title,responsible,due_date,timing,offset_days when=row.done
 lock Task fields=done,completed_at,completion_note when=row.done
When
 crud Starter by=hr fields=name,employee,department create_fields=name,employee,start_date,department delete=none
 crud Task by=hr fields=starter,title,responsible create_fields=starter,title,responsible,due_date when=not row.done delete=none
 # Preview a start-date change without changing the starter or any task.
 scenario preview_schedule(starter:Starter,proposed_date:date) by=hr
  do
   create SchedulePreview {starter,previous_date=starter.start_date,proposed_date} as preview
   for task in Task as task where task.starter==starter limit=500
    if task.timing==relative and not task.done
     create DeadlinePreview {parent=preview,task,previous_date=task.due_date,proposed_date=add_days(proposed_date,task.offset_days),was_done=task.done,timing=task.timing,offset_days=task.offset_days,already_overdue=task.due_date<local_date(now,"Europe/Brussels"),becomes_past_due=add_days(proposed_date,task.offset_days)<local_date(now,"Europe/Brussels")} as line
    else
     create DeadlinePreview {parent=preview,task,previous_date=task.due_date,proposed_date=task.due_date,was_done=task.done,timing=task.timing,offset_days=task.offset_days,already_overdue=false,becomes_past_due=false} as fixed_line
 # Confirmation rejects a changed task set or stale preview before any final commit.
 scenario confirm_schedule(preview:SchedulePreview,confirm_past_due:bool) by=hr
  require preview.status==prepared and preview.prepared_by==actor
  require preview.starter.start_date==preview.previous_date
  require count(Task as task where task.starter==preview.starter)==count(DeadlinePreview as line where line.parent==preview)
  do
   for line in DeadlinePreview as line where line.parent==preview limit=500
    require line.task.starter==preview.starter and line.task.due_date==line.previous_date
    require line.task.done==line.was_done and line.task.timing==line.timing and line.task.offset_days==line.offset_days
    require confirm_past_due or (not line.already_overdue and not line.becomes_past_due)
    if line.timing==relative and not line.was_done
     require line.already_overdue==(line.previous_date<local_date(now,"Europe/Brussels")) and line.becomes_past_due==(line.proposed_date<local_date(now,"Europe/Brussels"))
     set line.task {due_date=line.proposed_date}
   set preview.starter {start_date=preview.proposed_date}
   set preview {status=confirmed}
 # Cancellation retains the previous start date and every task deadline.
 scenario cancel_schedule(preview:SchedulePreview) by=hr
  require preview.status==prepared and preview.prepared_by==actor
  do set preview {status=cancelled}
 # HR deliberately converts an unfinished task after reviewing its calculated date.
 scenario use_relative(task:Task,offset_days:int,expected_date:date,confirm_past_due:bool) by=hr
  require not task.done
  require expected_date==add_days(task.starter.start_date,offset_days)
  require confirm_past_due or (task.due_date>=local_date(now,"Europe/Brussels") and expected_date>=local_date(now,"Europe/Brussels"))
  do set task {timing=relative,offset_days,due_date=expected_date}
 scenario use_fixed(task:Task,due_date:date) by=hr
  require not task.done
  do set task {timing=fixed,due_date}
 # The teammate responsible for a task records its completion.
 scenario complete(task:Task,note:text?) by=members
  require task.responsible==actor and not task.done
  do set task {done=true,completed_at=now,completion_note=note}
 # HR can hand unfinished work to another teammate.
 scenario reassign(task:Task,responsible:user) by=hr
  require not task.done
  do set task {responsible}
Then
 preferences { view:enum(unfinished,finished)=unfinished }
 page / title="Onboarding tasks"
  breadcrumbs
  card "Assign onboarding work"
   form Task.create
    input starter
    input title
    input responsible
    input due_date
  card "Assigned tasks and progress"
   tabs preferences.view
   list Task as task where (preferences.view==unfinished and not task.done) or (preferences.view==finished and task.done) order=due_date search=title empty="No onboarding tasks"
    text row.starter.name,row.title,row.responsible,row.due_date
    badge row.done
    edit
    action complete
    action reassign
    action use_relative
    action use_fixed
    collapse "Completion record"
     text row.completed_at,row.completion_note
     history
    pagination
 page /starters title="New starters"
  breadcrumbs
  card "Register a new starter"
   form Starter.create
    input name
    input employee
    input start_date
    input department
  card "New starters"
   list Starter order=start_date search=name empty="No new starters"
    text row.name,row.department,row.start_date
    action preview_schedule
    edit
    pagination
 page /schedule title="Review schedule changes"
  list SchedulePreview order=-created
   text row.starter.name,row.previous_date,row.proposed_date
   badge row.status
   list row.DeadlinePreview
    text row.task.title,row.previous_date,row.proposed_date
    badge row.was_done
    badge row.timing
    badge row.already_overdue
    badge row.becomes_past_due
   action confirm_schedule
   action cancel_schedule
Finance · Illustrative application

Expense approvals

Collect expense claims with their purpose, amount and approval status. Give reviewers an approval step that prevents people from approving their own claims.

Read the full expense approvals story →

Explore a larger application

Read the CanLang

Expenses.can
Keep the amount and authority with the claim.

Each expense stores its purpose, exact money amount, submitter and status. Employees see their own claims; reviewers can read claims for review.

Expenses.canExcerpt
Given
 role reviewer
 Expense { purpose:text trim, amount:money, submitted_by:user server=actor, status:enum(submitted,approved)=submitted }
 policy Expense read=members where=row.submitted_by==actor
 policy Expense read=reviewer
 invariant Expense: row.amount.minor>0
 lock Expense fields=purpose,amount,submitted_by when=row.status==approved
Expense approvalsIllustration
Purpose
Client travel
Amount
EUR 85.00
Submitted by
Sam
Status
Submitted

EmployeeReads their own expense claims.

ReviewerCan read claims for review.

Express the expense policy as business rules.

Amounts must be positive. A reviewer can approve a submitted claim from another teammate; self-approval and repeated approval are blocked.

Expenses.canExcerpt
When
 crud Expense by=members fields=purpose,amount update=none delete=none
 # Approve a submitted expense from another teammate.
 scenario approve(expense:Expense) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  do set expense {status=approved}
Expense approvalsIllustration
SubmittedApproved
  • Alex reviews Sam’s claimA reviewer can approve someone else’s claim.
  • Approving your own claimBlocked, even if you have the reviewer role.
  • A zero or negative claim amountBlocked by the amount invariant.
Connect submission and approval in one view.

Employees submit claims through a form. Reviewers see the purpose, amount and status beside the approval action, using the same record and rules.

Expenses.canExcerpt
Then
 page / title="Expense approvals"
  form Expense.create
  list Expense
   text row.purpose,row.amount
   badge row.status
   action approve
View the complete expense approvals source
# Submit expenses and let another teammate review them.
app Expenses
Given
 role reviewer
 Expense { purpose:text trim, amount:money, submitted_by:user server=actor, status:enum(submitted,approved)=submitted }
 policy Expense read=members where=row.submitted_by==actor
 policy Expense read=reviewer
 invariant Expense: row.amount.minor>0
 lock Expense fields=purpose,amount,submitted_by when=row.status==approved
When
 crud Expense by=members fields=purpose,amount update=none delete=none
 # Approve a submitted expense from another teammate.
 scenario approve(expense:Expense) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  do set expense {status=approved}
Then
 page / title="Expense approvals"
  form Expense.create
  list Expense
   text row.purpose,row.amount
   badge row.status
   action approve

See the rules in action

Interactive example
View CanLangBack to app
Expenses.canSource

Expense approvals · compact interactive example

# Submit expenses and let another teammate review them.
app Expenses
Given
 role reviewer
 Expense { purpose:text trim, amount:money, submitted_by:user server=actor, status:enum(submitted,approved)=submitted }
 policy Expense read=members where=row.submitted_by==actor
 policy Expense read=reviewer
 invariant Expense: row.amount.minor>0
 lock Expense fields=purpose,amount,submitted_by when=row.status==approved
When
 crud Expense by=members fields=purpose,amount update=none delete=none
 # Approve a submitted expense from another teammate.
 scenario approve(expense:Expense) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  do set expense {status=approved}
Then
 page / title="Expense approvals"
  form Expense.create
  list Expense
   text row.purpose,row.amount
   badge row.status
   action approve

Interactive example

Expense approvalsExample app
Submit an expense

Change roles, then try an action.

Expenses
  • Client travel · EUR 85.00Submitted
  • Office supplies · EUR 24.50Approved

Interactive illustration of the source’s rules. Example data stays in this page.

First version · Expanded illustrative source

Connect expense approval to payment

An illustrative expense workflow with cost centres, reviewer decisions and a separate finance step to record payment.

Additional workflows in illustrative source. The interactive demo above covers the smaller example.

Claims with financial context

Expenses reference a cost centre and store an exact money amount. An invariant requires a positive amount; employees see their own claims.

Separate review and payment

Reviewers approve another teammate’s claim. Finance records a payment reference after approval; locks preserve claim and decision details.

Traceable decisions

The expense list shows status, approval notes and payment records beside governed actions and history. Finance maintains cost centres on a second page.

Show full sourceHide full sourceExpenses.can · 77 lines
# Review expense claims and record the payment after approval.
app Expenses
Given
 role reviewer
 role finance
 CostCentre {
  code:text trim unique max=30 label="Cost centre code",
  name:text trim max=120 label="Cost centre",
  active:bool=true label="Accepts expenses"
 }
 Expense {
  purpose:text trim max=300 label="Expense purpose",
  amount:money label="Amount",
  cost_centre:CostCentre label="Cost centre",
  submitted_by:user server=actor label="Submitted by",
  status:enum(submitted,approved,paid)=submitted label="Status",
  decision_note:text? label="Decision note",
  approved_by:user? label="Approved by",
  approved_at:datetime? label="Approved at",
  payment_reference:text? label="Payment reference",
  paid_at:datetime? label="Paid at"
 }
 policy CostCentre read=members
 policy Expense read=members where=row.submitted_by==actor
 policy Expense read=reviewer
 policy Expense read=finance
 invariant Expense: row.amount.minor>0
 invariant Expense: trim(row.purpose)!=""
 lock Expense fields=purpose,amount,cost_centre,submitted_by
 lock Expense fields=approved_by,approved_at,decision_note when=row.status!=submitted
 lock Expense fields=status,payment_reference,paid_at when=row.status==paid
When
 crud CostCentre by=finance fields=code,name,active delete=none
 crud Expense by=members fields=purpose,amount,cost_centre update=none delete=none
 # A reviewer approves someone else's positive expense claim.
 scenario approve(expense:Expense,note:text?) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  require expense.cost_centre.active
  do set expense {status=approved,decision_note=note,approved_by=actor,approved_at=now}
 # Finance records a payment only after the claim is approved.
 scenario record_payment(expense:Expense,reference:text) by=finance
  require expense.status==approved and trim(reference)!=""
  do set expense {status=paid,payment_reference=trim(reference),paid_at=now}
Then
 preferences { status:Expense.status=submitted }
 page / title="Expense approvals"
  breadcrumbs
  card "Submit an expense"
   form Expense.create
    textarea purpose
    input amount
    input cost_centre
  card "Claims, approvals and payments"
   tabs preferences.status
   list Expense as expense where expense.status==preferences.status order=-created empty="No expense claims"
    badge row.status
    text row.purpose,row.amount,row.cost_centre.name,row.submitted_by
    action approve
    action record_payment
    collapse "Decision and payment record"
     text row.decision_note,row.approved_by,row.approved_at
     text row.payment_reference,row.paid_at
     history
    pagination
 page /cost-centres title="Cost centres"
  breadcrumbs
  card "Add a cost centre"
   form CostCentre.create
    input code
    input name
    input active
  card "Cost centres"
   list CostCentre order=code search=name empty="No cost centres"
    text row.code,row.name
    badge row.active
    edit
    pagination
A later change · Revised illustrative source

Correct a claim without losing the original

The revised expense workflow records a reason for return and allows the submitter to create one linked replacement with fresh approval and payment records.

Checked with v0.1.1; targeted replacement checks pass. Full workflow and existing-data transition still need verification.

Keep the submitted history

Returned details and the return decision remain protected; that record cannot progress to approval or payment.

One current replacement

Only the submitter creates a direct replacement. Further corrections continue the chain from that claim, preserving one branch.

Fresh business review

The replacement starts at Submitted. It carries no approval or payment forward, and ordinary visibility and independent approval still apply.

Show full sourceHide full sourceExpensesCorrection.can · 99 lines
# Revised application: return a submission and preserve its replacement chain.
app Expenses
Given
 role reviewer
 role finance
 CostCentre {
  code:text trim unique max=30 label="Cost centre code",
  name:text trim max=120 label="Cost centre",
  active:bool=true label="Accepts expenses"
 }
 Expense {
  purpose:text trim max=300 label="Expense purpose",
  amount:money label="Amount",
  cost_centre:CostCentre label="Cost centre",
  submitted_by:user server=actor label="Submitted by",
  status:enum(submitted,returned,approved,paid)=submitted label="Status",
  replaces_key:text? unique,
  replaces:Expense? label="Corrects submission",
  returned_by:user? label="Returned by",
  returned_at:datetime? label="Returned at",
  return_reason:text? label="Correction reason",
  decision_note:text? label="Decision note",
  approved_by:user? label="Approved by",
  approved_at:datetime? label="Approved at",
  payment_reference:text? label="Payment reference",
  paid_at:datetime? label="Paid at"
 }
 policy CostCentre read=members
 policy Expense read=members where=row.submitted_by==actor
 policy Expense read=reviewer
 policy Expense read=finance
 invariant Expense: row.amount.minor>0
 invariant Expense: trim(row.purpose)!=""
 lock Expense fields=purpose,amount,cost_centre,submitted_by,replaces,replaces_key
 lock Expense fields=returned_by,returned_at,return_reason when=row.status!=submitted
 lock Expense fields=status when=row.status==returned
 lock Expense fields=approved_by,approved_at,decision_note when=row.status!=submitted
 lock Expense fields=status,payment_reference,paid_at when=row.status==paid
When
 crud CostCentre by=finance fields=code,name,active delete=none
 crud Expense by=members fields=purpose,amount,cost_centre update=none delete=none
 # Return only an unapproved submission, keeping its details intact.
 scenario return_for_correction(expense:Expense,reason:text) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  require trim(reason)!=""
  do set expense {status=returned,returned_by=actor,returned_at=now,return_reason=trim(reason)}
 # A unique immutable key admits one replacement per original, atomically.
 # Only this guarded scenario supplies the key; ordinary creation cannot set it.
 scenario replace_submission(original:Expense,purpose:text,amount:money,cost_centre:CostCentre) by=members
  require original.status==returned and original.submitted_by==actor
  require amount.minor>0 and cost_centre.active and trim(purpose)!=""
  do create Expense {purpose,amount,cost_centre,replaces=original,replaces_key=original.id} as replacement
 # A reviewer approves someone else's positive expense claim.
 scenario approve(expense:Expense,note:text?) by=reviewer
  require expense.status==submitted and expense.submitted_by!=actor
  require expense.cost_centre.active
  do set expense {status=approved,decision_note=note,approved_by=actor,approved_at=now}
 # Finance records a payment only after the claim is approved.
 scenario record_payment(expense:Expense,reference:text) by=finance
  require expense.status==approved and trim(reference)!=""
  do set expense {status=paid,payment_reference=trim(reference),paid_at=now}
Then
 preferences { status:Expense.status=submitted }
 page / title="Expense approvals"
  breadcrumbs
  card "Submit an expense"
   form Expense.create
    textarea purpose
    input amount
    input cost_centre
  card "Claims, approvals and payments"
   tabs preferences.status
   list Expense as expense where expense.status==preferences.status order=-created empty="No expense claims"
    badge row.status
    text row.purpose,row.amount,row.cost_centre.name,row.submitted_by
    action return_for_correction
    action approve
    action record_payment
    collapse "Decision and payment record"
     text row.replaces,row.return_reason,row.returned_by,row.returned_at
     text row.decision_note,row.approved_by,row.approved_at
     text row.payment_reference,row.paid_at
     history
    pagination
 page /cost-centres title="Cost centres"
  breadcrumbs
  card "Add a cost centre"
   form CostCentre.create
    input code
    input name
    input active
  card "Cost centres"
   list CostCentre order=code search=name empty="No cost centres"
    text row.code,row.name
    badge row.active
    edit
    pagination
 page /corrections title="Correct a returned submission"
  form replace_submission
Facilities · Illustrative application

Maintenance tickets

Collect maintenance issues with a location and description. Give technicians a shared queue and a clear way to mark each reported issue resolved.

Read the full maintenance tickets story →

Explore a larger application

Read the CanLang

Maintenance.can
Keep the location, issue and status together.

A ticket stores the location, issue, reporter and status. Employees can read their own reports; technicians can read the maintenance queue.

Maintenance.canExcerpt
Given
 role technician
 Ticket { location:text trim, issue:text trim, reported_by:user server=actor, status:enum(open,resolved)=open }
 policy Ticket read=members where=row.reported_by==actor
 policy Ticket read=technician
 lock Ticket fields=location,issue,reported_by when=row.status==resolved
Maintenance ticketsIllustration
Location
Floor 2
Issue
Air conditioning not cooling
Reported by
Sam
Status
Open

EmployeeReads their own reported issues.

TechnicianCan read the maintenance queue.

Let technicians resolve open tickets.

Employees report issues. Only a technician can resolve an open ticket, and a resolved ticket cannot be resolved again.

Maintenance.canExcerpt
When
 crud Ticket by=members fields=location,issue update=none delete=none
 # Resolve an open maintenance ticket.
 scenario resolve(ticket:Ticket) by=technician
  require ticket.status==open
  do set ticket {status=resolved}
Maintenance ticketsIllustration
OpenResolved
  • A technician resolves an open ticketResolution is allowed.
  • An employee resolves a ticketThe technician role is required.
  • Resolving a resolved ticket againBlocked by the open-ticket rule.
Turn reports into a shared maintenance queue.

A reporting form and ticket list use the same records. Technicians get a resolve action governed by the ticket’s workflow rules.

Maintenance.canExcerpt
Then
 page / title="Maintenance tickets"
  form Ticket.create
  list Ticket
   text row.location,row.issue
   badge row.status
   action resolve
View the complete maintenance tickets source
# Report maintenance issues and let a technician resolve them.
app Maintenance
Given
 role technician
 Ticket { location:text trim, issue:text trim, reported_by:user server=actor, status:enum(open,resolved)=open }
 policy Ticket read=members where=row.reported_by==actor
 policy Ticket read=technician
 lock Ticket fields=location,issue,reported_by when=row.status==resolved
When
 crud Ticket by=members fields=location,issue update=none delete=none
 # Resolve an open maintenance ticket.
 scenario resolve(ticket:Ticket) by=technician
  require ticket.status==open
  do set ticket {status=resolved}
Then
 page / title="Maintenance tickets"
  form Ticket.create
  list Ticket
   text row.location,row.issue
   badge row.status
   action resolve

See the rules in action

Interactive example
View CanLangBack to app
Maintenance.canSource

Maintenance tickets · compact interactive example

# Report maintenance issues and let a technician resolve them.
app Maintenance
Given
 role technician
 Ticket { location:text trim, issue:text trim, reported_by:user server=actor, status:enum(open,resolved)=open }
 policy Ticket read=members where=row.reported_by==actor
 policy Ticket read=technician
 lock Ticket fields=location,issue,reported_by when=row.status==resolved
When
 crud Ticket by=members fields=location,issue update=none delete=none
 # Resolve an open maintenance ticket.
 scenario resolve(ticket:Ticket) by=technician
  require ticket.status==open
  do set ticket {status=resolved}
Then
 page / title="Maintenance tickets"
  form Ticket.create
  list Ticket
   text row.location,row.issue
   badge row.status
   action resolve

Interactive example

Maintenance ticketsExample app
Report a maintenance issue

Change roles, then try an action.

Tickets
  • Floor 2 · Air conditioning not coolingOpen
  • Reception · Light replacementResolved

Interactive illustration of the source’s rules. Example data stays in this page.

First version · Expanded illustrative source

Keep the work behind a maintenance ticket

An illustrative maintenance workflow linking building locations, issue priorities, technician work notes and recorded resolutions.

Additional workflows in illustrative source. The interactive demo above covers the smaller example.

Locations, tickets and work notes

Tickets reference building locations. Each work note belongs to its ticket and records its author and time; reporters see their own tickets and notes.

A controlled repair record

Technicians add notes to open tickets and record a resolution. Notes are locked, and resolving a ticket locks its report and resolution details.

The queue and its context

Status tabs, priority badges, nested work notes, resolution details and history share a ticket screen. A separate page maintains building locations.

Show full sourceHide full sourceMaintenance.can · 80 lines
# Report maintenance issues and keep a technician's work record.
app Maintenance
Given
 role technician
 Location {
  name:text trim unique max=120 label="Location",
  building:text trim max=120 label="Building",
  floor:text trim max=40 label="Floor"
 }
 Ticket {
  location:Location label="Location",
  issue:text trim max=1000 label="Issue",
  priority:enum(routine,urgent)=routine label="Priority",
  reported_by:user server=actor label="Reported by",
  status:enum(open,resolved)=open label="Status",
  resolved_by:user? label="Resolved by",
  resolved_at:datetime? label="Resolved at",
  resolution:text? label="Resolution"
 }
 WorkNote in Ticket {
  note:text trim max=1000 label="Work note",
  author:user server=actor label="Technician",
  recorded_at:datetime server=now label="Recorded at"
 }
 policy Location read=members
 policy Ticket read=members where=row.reported_by==actor
 policy Ticket read=technician
 policy WorkNote read=members where=row.parent.reported_by==actor
 policy WorkNote read=technician
 invariant Ticket: trim(row.issue)!=""
 invariant WorkNote: trim(row.note)!=""
 lock Ticket fields=location,issue,priority,reported_by when=row.status==resolved
 lock Ticket fields=status,resolved_by,resolved_at,resolution when=row.status==resolved
 lock WorkNote fields=note,author,recorded_at
When
 crud Location by=technician fields=name,building,floor delete=none
 crud Ticket by=members fields=location,issue,priority update=none delete=none
 # Append a visit or diagnostic note while a ticket is open.
 scenario record_work(ticket:Ticket,note:text) by=technician
  require ticket.status==open and trim(note)!=""
  do create WorkNote {parent=ticket,note=trim(note)} as work_note
 # Resolve the issue with a description of the repair.
 scenario resolve(ticket:Ticket,resolution:text) by=technician
  require ticket.status==open and trim(resolution)!=""
  do set ticket {status=resolved,resolution=trim(resolution),resolved_by=actor,resolved_at=now}
Then
 preferences { status:Ticket.status=open }
 page / title="Maintenance tickets"
  breadcrumbs
  card "Report a maintenance issue"
   form Ticket.create
    input location
    textarea issue
    input priority
  card "Maintenance queue"
   tabs preferences.status
   list Ticket as ticket where ticket.status==preferences.status order=-created empty="No maintenance tickets"
    badge row.status
    badge row.priority
    text row.location.name,row.issue,row.reported_by
    action record_work
    action resolve
    collapse "Work and resolution"
     list row.WorkNote order=recorded_at empty="No work notes"
      text row.note,row.author,row.recorded_at
     text row.resolution,row.resolved_by,row.resolved_at
     history
    pagination
 page /locations title="Building locations"
  breadcrumbs
  card "Register a location"
   form Location.create
    input name
    input building
    input floor
  card "Locations"
   list Location search=name order=name empty="No locations"
    text row.name,row.building,row.floor
    edit
    pagination
A later change · Revised illustrative source

Connect a recurring report to the earlier repair

The revised maintenance workflow gives a new occurrence its own work history and lets technicians record, correct or remove a qualifying earlier-ticket link.

Checked with v0.1.1; targeted relationship checks pass. Full workflow and existing-data transition still need verification.

Separate occurrences

The earlier ticket stays resolved. The new report owns its reporter, notes and eventual resolution.

An accountable relationship

One earlier resolved ticket at the same location supplies context. Every link change records its actor, time and reason while the new ticket is open.

Preserve access

Related-ticket fields and link history are restricted to technicians, who can already read both tickets. Reporter access to their own ticket and work notes is preserved.

Show full sourceHide full sourceMaintenanceRecurrence.can · 105 lines
# Revised application: connect a new occurrence to an earlier resolved repair.
# Only technicians see related-ticket links and their change history.
app Maintenance
Given
 role technician
 Location {
  name:text trim unique max=120 label="Location",
  building:text trim max=120 label="Building",
  floor:text trim max=40 label="Floor"
 }
 Ticket {
  location:Location label="Location",
  issue:text trim max=1000 label="Issue",
  priority:enum(routine,urgent)=routine label="Priority",
  reported_by:user server=actor label="Reported by",
  status:enum(open,resolved)=open label="Status",
  resolved_by:user? label="Resolved by",
  resolved_at:datetime? label="Resolved at",
  resolution:text? label="Resolution",
  earlier:Ticket? label="Earlier resolved ticket"
 }
 WorkNote in Ticket {
  note:text trim max=1000 label="Work note",
  author:user server=actor label="Technician",
  recorded_at:datetime server=now label="Recorded at"
 }
 LinkChange {
  ticket:Ticket label="Current ticket",
  previous:Ticket? label="Previous link",
  selected:Ticket? label="Selected earlier ticket",
  reason:text trim label="Why the relationship changed",
  author:user server=actor label="Changed by",
  recorded_at:datetime server=now label="Changed at"
 }
 policy LinkChange read=technician
 lock LinkChange fields=ticket,previous,selected,reason,author,recorded_at
 policy Location read=members
 policy Ticket read=members fields=location,issue,priority,reported_by,status,resolved_by,resolved_at,resolution where=row.reported_by==actor
 policy Ticket read=technician
 policy WorkNote read=members where=row.parent.reported_by==actor
 policy WorkNote read=technician
 invariant Ticket: trim(row.issue)!=""
 invariant WorkNote: trim(row.note)!=""
 lock Ticket fields=location,issue,priority,reported_by,earlier when=row.status==resolved
 lock Ticket fields=status,resolved_by,resolved_at,resolution when=row.status==resolved
 lock WorkNote fields=note,author,recorded_at
When
 crud Location by=technician fields=name,building,floor delete=none
 crud Ticket by=members fields=location,issue,priority update=none delete=none
 # Append a visit or diagnostic note while a ticket is open.
 scenario record_work(ticket:Ticket,note:text) by=technician
  require ticket.status==open and trim(note)!=""
  do create WorkNote {parent=ticket,note=trim(note)} as work_note
 # Set, correct or remove the one earlier-ticket reference while open.
 scenario relate(ticket:Ticket,earlier:Ticket?,reason:text) by=technician
  require ticket.status==open and trim(reason)!=""
  require earlier==null or (earlier!=ticket and earlier.status==resolved and earlier.location==ticket.location)
  do
   create LinkChange {ticket,previous=ticket.earlier,selected=earlier,reason=trim(reason)} as change
   set ticket {earlier}
 # Resolve the issue with a description of the repair.
 scenario resolve(ticket:Ticket,resolution:text) by=technician
  require ticket.status==open and trim(resolution)!=""
  do set ticket {status=resolved,resolution=trim(resolution),resolved_by=actor,resolved_at=now}
Then
 preferences { status:Ticket.status=open }
 page / title="Maintenance tickets"
  breadcrumbs
  card "Report a maintenance issue"
   form Ticket.create
    input location
    textarea issue
    input priority
  card "Maintenance queue"
   tabs preferences.status
   list Ticket as ticket where ticket.status==preferences.status order=-created empty="No maintenance tickets"
    badge row.status
    badge row.priority
    text row.location.name,row.issue,row.reported_by
    action record_work
    action resolve
    collapse "Work and resolution"
     list row.WorkNote order=recorded_at empty="No work notes"
      text row.note,row.author,row.recorded_at
     text row.resolution,row.resolved_by,row.resolved_at
     collapse "Earlier repair · technician access"
      text row.earlier
      list LinkChange as link_change where link_change.ticket==ticket order=recorded_at empty="No link changes"
       text row.previous,row.selected,row.reason,row.author,row.recorded_at
     history
    pagination
 page /locations title="Building locations"
  breadcrumbs
  card "Register a location"
   form Location.create
    input name
    input building
    input floor
  card "Locations"
   list Location search=name order=name empty="No locations"
    text row.name,row.building,row.floor
    edit
    pagination
 page /relationships title="Connect related repairs"
  form relate

Extend where your application needs more.

Use the standard business foundations first. Where your application needs something specific, describe it with a generation prompt.

$"..."
Describe what to generate.
$Name
Apply a named style.

Keep $"..." on the same line as its declaration, with no space after $. The excerpts below preserve source lines; scroll long lines sideways.

Traits · model and field behavior

Add fields or constraints to a model, or constraints to a stored field. Define a named trait to reuse it, or write an inline trait for one model or field.

Given · named, model and field traits
Given
 trait Trackable $"Add done:bool=false."
 Task {title:text} Trackable

 Note {title:text} trait $"Add pinned:bool=false."
 Contact {name:text trait $"Trim; require 1–80 characters."}

Apply a named trait by its name, as with Trackable. The $Name form is reserved for styles.

Keywords · reusable custom behavior

Define a keyword with typed parameters, then reuse it in your source. Here, key treats “Book” and “BOOK” as duplicate tag names while preserving the stored spelling.

Given · compact unique-key example
Given
 keyword key(
  target:model,
  field:field(target,text)
 ) $"NFKC + A-Z lowercase key."
 Tag {name:text}
 key target=Tag field=Tag.name

NFKC normalizes equivalent Unicode forms; A–Z lowercase makes the key ignore ASCII letter case. CanLang enforces uniqueness, permissions and transactions.

Original unique-key example on GitHub

Styles · appearance and layout

Define a named style and apply it with $Name, or attach a one-off prompt directly to a visual component. Styles control presentation; permissions and business rules stay in the application source.

Then · named style, reuse and inline style
Then
 style Calm $"Use compact spacing."
 page / title="Workspace"
  card "Tasks" $Calm
   text "Tasks"
  card "Notes" $"Use compact spacing."
   text "Notes"

The suffix follows the component’s options, on the same line. It styles that component rather than declaring a child element.

Views · custom rendering

Describe how a view renders its typed record and declared content. Its first prompt generates the renderer. An optional second suffix supplies the view’s root style: either a named style or another prompt.

Then · renderer with a named or inline root style
Then
 view TaskTile(row:Task) $"Arrange the canonical card." $Calm
  card row.title
   text row.title

 view NoteTile(row:Note) $"Arrange the canonical card." $"Use compact spacing."
  card row.title
   text row.title

Reuse the view with show and a row binding. A show instance can also carry a style suffix.

Illustrative generation lifecycle

  1. Describe$"..."Static generation prompt
  2. Generate explicitlycan genDuring development
  3. KeepEditable codeOrdinary builds reuse it

Generation produces saved implementation during development; ordinary builds reuse it.

Generate during development. Keep the code.

Run can gen explicitly to produce saved, editable implementation. Ordinary builds reuse it; running your app does not rerun these prompts.

How $ generation works

Your first working app

Run your first task app.

Save this source, start a local preview, then add, edit and archive a task in your browser. CanLang runs on its own: no Soda OS or Cloudflare account required.

First, install CanLang.

Run this command in a terminal on macOS Apple Silicon or Linux x64 with glibc 2.39 or newer. The bundle includes its own Node runtime and dependencies; no manual Rust, Node, Bun, npm or browser installation is needed.

CanLang v0.1.1 release on GitHub

Install CanLang

curl -fsSL https://sodaos.dev/CanLang/install.sh | bash

Open a new terminal, then run can --version.

  1. Save the application source.

    Create a file named tasks.can in a working folder of your choice, then copy and paste the source below into it.

    Indent with one space per level. Run the following commands from the folder where you saved tasks.can.

    Given
    A task record, a trimmed title and read access for team members.
    When
    Standard create, edit and archive operations for members.
    Then
    A form and task list connected to those same records and rules.

    tasks.can

    # Track the team's work.
    app Tasks
    Given
     export Todo { title:text trim, done:bool=false }
     policy Todo read=members
    When
     crud Todo by=members fields=title,done
    Then
     page / title="Tasks"
      form Todo.create fields=title,done display=inline
       input title
       checkbox done
      list Todo
       text row.title,row.done
       edit fields=title,done
       delete
    
  2. Check it and start a session.

    In a terminal, open the folder containing tasks.can. Check the source, start the Tasks session and check its preview readiness.

    can check tasks.can
    can dev start --source tasks.can --app Tasks
    can dev check --app Tasks
    can dev preview.status --app Tasks

    The session controls return JSON. Fix any check diagnostics before continuing. In the preview.status response, result.state must be ready and result.stale must be false. If the preview is unavailable, inspect the check response, fix the reported problem and run can dev check --app Tasks again.

  3. Open it and try a task.

    When the preview is ready, request its browser link in the same terminal.

    can dev preview.open --app Tasks

    Open result.url in your browser promptly, exactly as returned. The link works once and expires after 30 seconds; run can dev preview.open --app Tasks again for a fresh link if needed. Sign in with the email and password of an account from result.actors, then select one of its teams.

    Your first app is running when you can:

    1. Add a task.
    2. Use the task’s Submit control to open its edit panel, mark it done and submit the change.
    3. Close the panel, archive the task and reload to check the result.

    This is a protected local preview. Its port, access token and seeded accounts belong to your session; keep the URL and credentials private.

04 / Keep improving

One new rule.
Same task app.

Keep task titles to 80 characters. A small edit in your source gives the form a clear boundary—and gives you something concrete to check.

Change one field.

In tasks.can, add max=80 to the Todo title field. This is an optional example rule, not a language-wide limit.

tasks.can / Todo · field excerpt
Before
title:text trim
After + max=80
title:text trim max=80

Save the file. Check the source.

can check tasks.can

Then compare both sides of the boundary in the illustration.

View CanLangBack to app
tasks.canComplete example

The starter task app with an 80-character title limit.

# Track the team's work.
app Tasks
Given
 export Todo { title:text trim max=80, done:bool=false }
 policy Todo read=members
When
 crud Todo by=members fields=title,done
Then
 page / title="Tasks"
  form Todo.create fields=title,done display=inline
   input title
   checkbox done
  list Todo
   text row.title,row.done
   edit fields=title,done
   delete

Task app · Illustration

Compare title lengths
TasksTeam workspace
Task 004

Edit task

Title 80 / 80
Prepare the quarterly equipment checklist for the operations team before Monday.

Title is within the 80-character limit.

Done
Ready to submitAccepted
Title 81 / 80
Prepare the quarterly equipment checklist for the operations team before Tuesday.

Keep the title to 80 characters. Remove 1 character.

Done
Title needs an editRefused

Illustrative form states for the proposed rule. Choose a title length to compare the result.

A change is only done
when you check its effects.

  1. Test the boundary

    Try a permitted title and an overlong one. Exactly 80 characters should pass; 81 should be refused with a useful message.

  2. Keep earlier work

    Keep a valid earlier task. Verify it survives the rebuild and the new rule applies. A browser reload is a separate check.

  3. Handle longer records

    Decide what happens to existing titles over 80 characters. Never silently truncate someone’s work.

Inspect the compiler output

To inspect the application artifact separately, run this from the same folder:

can compile --format=json tasks.can > tasks.artifact.json

Compilation prints a JSON artifact; > saves it as tasks.artifact.json beside your source. Inspect its generated modules and application definition. A successful source check validates names, types and rules; compilation also checks emission and can report additional diagnostics.

The artifact is a development output. The preview session reads tasks.can directly.

Change the app or end your session

Change the task fields or add one of your department’s rules, check the source again and explore the updated preview. Run can dev check --app Tasks after editing, then check readiness again.

For session help and current state:

can dev help
can dev status --app Tasks

If a preview fails, inspect the check response’s preview and preview_error before opening it.

When you finish, stop the session you own in the same terminal. Its preview access ends with it.

can dev stop --app Tasks

Use the session controls in can dev help to select the right owner if another source has an active session.

Building CanLang itself?

Building from source is optional, for contributors changing CanLang itself. The standalone installer includes its Node runtime and dependencies.

Browser automation with Chrome/Playwright is optional, separate development tooling.

Read the optional contributor setup guide