Files
juxt__allium/references/patterns.md
T

86 KiB

Complete patterns

This library contains reusable patterns for common SaaS scenarios. Each pattern demonstrates specific Allium language features and can be adapted to your domain.

Patterns elide common cross-cutting entities (Email, Notification, AuditLog, etc.) for brevity. In a real specification, declare these as external entities or define them in a shared module.

Pattern Key Features Demonstrated
Password Auth with Reset Temporal triggers, token lifecycle, defaults, surfaces
Role-Based Access Control Derived permissions, relationships, requires checks, surfaces
Invitation to Resource Join entities, permission levels, tokenised actions, surfaces
Soft Delete & Restore State machines, projections filtering deleted items
Notification Preferences Sum types for notification variants, user preferences, digest batching, surfaces
Usage Limits & Quotas Limit checks in requires, metered resources, plan tiers, surfaces
Comments with Mentions Nested entities, parsing triggers, cross-entity notifications, surfaces
Integrating Library Specs External spec references, configuration, config parameter references, responding to external triggers
Framework Integration Contract Contract declarations, expression-bearing invariants, contract references, programmatic surfaces

Pattern 1: Password Authentication with Reset

Demonstrates: Temporal triggers, token lifecycle, defaults, surfaces, multiple related rules

This pattern handles user registration, login and password reset: the foundation of most SaaS applications.

-- allium: 3
-- password-auth.allium

config {
    min_password_length: Integer = 12
    max_login_attempts: Integer = 5
    lockout_duration: Duration = 15.minutes
    reset_token_expiry: Duration = 1.hour
    session_duration: Duration = 24.hours
}

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity User {
    email: String
    password_hash: String          -- stored, never exposed
    status: active | locked | deactivated
    failed_login_attempts: Integer
    locked_until: Timestamp?

    -- Relationships
    sessions: Session with user = this
    reset_tokens: PasswordResetToken with user = this

    -- Projections
    active_sessions: sessions where status = active
    pending_reset_tokens: reset_tokens where status = pending

    -- Derived
    is_locked: status = locked and locked_until > now
}

entity Session {
    user: User
    created_at: Timestamp
    expires_at: Timestamp
    status: active | expired | revoked

    -- Derived
    is_valid: status = active and expires_at > now
}

entity PasswordResetToken {
    user: User
    created_at: Timestamp
    expires_at: Timestamp
    status: pending | used | expired

    -- Derived
    is_valid: status = pending and expires_at > now
}

------------------------------------------------------------
-- Registration
------------------------------------------------------------

rule Register {
    when: UserRegisters(email, password)

    requires: not exists User{email: email}
    requires: length(password) >= config.min_password_length

    ensures: User.created(
        email: email,
        password_hash: hash(password),    -- black box
        status: active,
        failed_login_attempts: 0
    )
    ensures: Email.created(
        to: email,
        template: welcome
    )
}

------------------------------------------------------------
-- Login
------------------------------------------------------------

rule LoginSuccess {
    when: UserLogsIn(email, password)

    let user = User{email}

    requires: exists user
    requires: not user.is_locked
    requires: verify(password, user.password_hash)    -- black box

    ensures: user.failed_login_attempts = 0
    ensures: Session.created(
        user: user,
        created_at: now,
        expires_at: now + config.session_duration,
        status: active
    )
}

rule LoginFailure {
    when: UserLogsIn(email, password)

    let user = User{email}

    requires: exists user
    requires: not user.is_locked
    requires: not verify(password, user.password_hash)

    ensures: user.failed_login_attempts = user.failed_login_attempts + 1
    ensures:
        if user.failed_login_attempts >= config.max_login_attempts:
            user.status = locked
            user.locked_until = now + config.lockout_duration
            Email.created(to: user.email, template: account_locked)
}

rule LoginAttemptWhileLocked {
    when: UserLogsIn(email, password)

    let user = User{email}

    requires: exists user
    requires: user.is_locked

    ensures: UserInformed(
        user: user,
        about: account_locked,
        data: { unlocks_at: user.locked_until }
    )
}

rule LockoutExpires {
    when: user: User.locked_until <= now

    requires: user.status = locked

    ensures: user.status = active
    ensures: user.failed_login_attempts = 0
    ensures: user.locked_until = null
}

------------------------------------------------------------
-- Logout
------------------------------------------------------------

rule Logout {
    when: UserLogsOut(session)

    requires: session.status = active

    ensures: session.status = revoked
}

rule SessionExpires {
    when: session: Session.expires_at <= now

    requires: session.status = active

    ensures: session.status = expired
}

------------------------------------------------------------
-- Password Reset
------------------------------------------------------------

rule RequestPasswordReset {
    when: UserRequestsPasswordReset(email)

    let user = User{email}

    requires: exists user
    requires: user.status in {active, locked}

    -- Invalidate any existing tokens
    ensures:
        for t in user.pending_reset_tokens:
            t.status = expired

    ensures:
        let token = PasswordResetToken.created(
            user: user,
            created_at: now,
            expires_at: now + config.reset_token_expiry,
            status: pending
        )
        Email.created(
            to: email,
            template: password_reset,
            data: { token: token }
        )
}

rule CompletePasswordReset {
    when: UserResetsPassword(token, new_password)

    requires: token.is_valid
    requires: length(new_password) >= config.min_password_length

    let user = token.user

    ensures: token.status = used
    ensures: user.password_hash = hash(new_password)
    ensures: user.status = active
    ensures: user.failed_login_attempts = 0
    ensures: user.locked_until = null

    -- Invalidate all existing sessions
    ensures:
        for s in user.active_sessions:
            s.status = revoked

    ensures: Email.created(
        to: user.email,
        template: password_changed
    )
}

rule ResetTokenExpires {
    when: token: PasswordResetToken.expires_at <= now

    requires: token.status = pending

    ensures: token.status = expired
}

------------------------------------------------------------
-- Actors
------------------------------------------------------------

actor AuthenticatedUser {
    identified_by: User where active_sessions.count > 0
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface Authentication {
    facing visitor: User

    provides:
        UserLogsIn(email, password)
        UserRegisters(email, password)
        UserRequestsPasswordReset(email)

    @guarantee NoSessionRequired
        -- Accessible without an existing session.

    @guidance
        -- Show lockout status and unlock time when user.is_locked.
        -- Validate password length client-side before submission.
}

surface PasswordReset {
    facing visitor: User

    context token: PasswordResetToken

    exposes:
        token.is_valid
        token.expires_at

    provides:
        UserResetsPassword(token, new_password)
            when token.is_valid

    @guarantee NoSessionRequired
        -- Accessible without an existing session.
}

surface AccountManagement {
    facing user: AuthenticatedUser

    exposes:
        user.email
        user.active_sessions
        user.active_sessions.count

    provides:
        for session in user.active_sessions:
            UserLogsOut(session)
        UserRequestsPasswordReset(user.email)
}

Key language features shown:

  • config block for configurable parameters (config.min_password_length, etc.)
  • Derived values (is_locked, is_valid)
  • Multiple rules for same trigger with different requires (login success vs failure)
  • Temporal triggers with guards (when: token: PasswordResetToken.expires_at <= now with requires: status = pending)
  • Projections for filtered collections (pending_reset_tokens)
  • Bulk updates with for iteration
  • Explicit let binding for created entities
  • Black box functions (hash(), verify())
  • Surfaces with facing declaration and for iteration in provides

Pattern 2: Role-Based Access Control (RBAC)

Demonstrates: Derived permissions, relationships, using permissions in requires clauses, surfaces

This pattern implements hierarchical roles where higher roles inherit permissions from lower ones.

-- allium: 3
-- rbac.allium

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity Role {
    name: String                    -- e.g., "viewer", "editor", "admin"
    permissions: Set<String>        -- e.g., { "documents.read", "documents.write" }
    inherits_from: Role?            -- optional parent role

    -- Derived: all permissions including inherited
    effective_permissions:
        permissions + (inherits_from?.effective_permissions ?? {})
}

entity User {
    email: String
    name: String
}

entity Workspace {
    name: String
    owner: User

    -- Relationships
    memberships: WorkspaceMembership with workspace = this
    documents: Document with workspace = this

    -- Projections
    members: memberships -> user
    admins: memberships where role.name = "admin" -> user
}

entity Document {
    workspace: Workspace
    created_by: User
    title: String
    content: String
}

entity DocumentView {
    user: User
    document: Document
    at: Timestamp
}

-- Join entity connecting User, Workspace, and Role
entity WorkspaceMembership {
    user: User
    workspace: Workspace
    role: Role
    joined_at: Timestamp

    -- Derived: check specific permissions
    can_read: "documents.read" in role.effective_permissions
    can_write: "documents.write" in role.effective_permissions
    can_admin: "workspace.admin" in role.effective_permissions
}

------------------------------------------------------------
-- Defaults
------------------------------------------------------------

default Role viewer = {
    name: "viewer",
    permissions: { "documents.read" }
}

default Role editor = {
    name: "editor",
    permissions: { "documents.write" },
    inherits_from: viewer
}

default Role admin = {
    name: "admin",
    permissions: { "workspace.admin", "members.manage" },
    inherits_from: editor
}

------------------------------------------------------------
-- Rules
------------------------------------------------------------

rule CreateWorkspace {
    when: UserCreatesWorkspace(user, name)

    ensures:
        let workspace = Workspace.created(
            name: name,
            owner: user
        )
        -- Owner automatically becomes admin
        WorkspaceMembership.created(
            user: user,
            workspace: workspace,
            role: admin,
            joined_at: now
        )
}

rule AddMember {
    when: AddMemberToWorkspace(actor, workspace, new_user, role)

    let actor_membership = WorkspaceMembership{user: actor, workspace: workspace}

    requires: actor_membership.can_admin
    requires: not exists WorkspaceMembership{user: new_user, workspace: workspace}

    ensures: WorkspaceMembership.created(
        user: new_user,
        workspace: workspace,
        role: role,
        joined_at: now
    )
    ensures: Email.created(
        to: new_user.email,
        template: added_to_workspace,
        data: { workspace: workspace, role: role }
    )
}

rule ChangeMemberRole {
    when: ChangeMemberRole(actor, workspace, target_user, new_role)

    let actor_membership = WorkspaceMembership{user: actor, workspace: workspace}
    let target_membership = WorkspaceMembership{user: target_user, workspace: workspace}

    requires: actor_membership.can_admin
    requires: exists target_membership
    requires: target_user != workspace.owner    -- can't change owner's role

    ensures: target_membership.role = new_role
}

rule RemoveMember {
    when: RemoveMemberFromWorkspace(actor, workspace, target_user)

    let actor_membership = WorkspaceMembership{user: actor, workspace: workspace}
    let target_membership = WorkspaceMembership{user: target_user, workspace: workspace}

    requires: actor_membership.can_admin
    requires: exists target_membership
    requires: target_user != workspace.owner    -- can't remove owner

    ensures: not exists target_membership
}

rule LeaveWorkspace {
    when: UserLeavesWorkspace(user, workspace)

    let membership = WorkspaceMembership{user, workspace}

    requires: exists membership
    requires: user != workspace.owner    -- owner can't leave

    ensures: not exists membership
}

------------------------------------------------------------
-- Managing permissions on roles
------------------------------------------------------------

rule GrantPermission {
    when: GrantPermission(actor, workspace, role, permission)

    let actor_membership = WorkspaceMembership{user: actor, workspace: workspace}

    requires: actor_membership.can_admin
    requires: permission not in role.effective_permissions

    ensures: role.permissions.add(permission)
}

rule RevokePermission {
    when: RevokePermission(actor, workspace, role, permission)

    let actor_membership = WorkspaceMembership{user: actor, workspace: workspace}

    requires: actor_membership.can_admin
    requires: permission in role.permissions    -- only direct, not inherited

    ensures: role.permissions.remove(permission)
}

------------------------------------------------------------
-- Using permissions in other rules
------------------------------------------------------------

rule CreateDocument {
    when: CreateDocument(user, workspace, title, content)

    let membership = WorkspaceMembership{user, workspace}

    requires: membership.can_write

    ensures: Document.created(
        workspace: workspace,
        created_by: user,
        title: title,
        content: content
    )
}

rule ViewDocument {
    when: ViewDocument(user, document)

    let membership = WorkspaceMembership{user: user, workspace: document.workspace}

    requires: membership.can_read

    ensures: DocumentView.created(user: user, document: document, at: now)
}

------------------------------------------------------------
-- Actors
------------------------------------------------------------

actor WorkspaceAdmin {
    within: Workspace
    identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_admin = true
}

actor WorkspaceEditor {
    within: Workspace
    identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_write = true
}

actor WorkspaceViewer {
    within: Workspace
    identified_by: User where WorkspaceMembership{user: this, workspace: within}.can_read = true
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface WorkspaceMemberManagement {
    facing admin: WorkspaceAdmin

    context workspace: Workspace

    exposes:
        workspace.name
        workspace.memberships
        workspace.admins

    provides:
        AddMemberToWorkspace(admin, workspace, new_user, role)
        ChangeMemberRole(admin, workspace, target_user, new_role)
            when target_user != workspace.owner
        RemoveMemberFromWorkspace(admin, workspace, target_user)
            when target_user != workspace.owner

    @guarantee OwnerProtection
        -- The workspace owner's role cannot be changed or removed.
}

surface WorkspaceDocuments {
    facing member: User

    context workspace: Workspace

    let membership = WorkspaceMembership{user: member, workspace: workspace}

    exposes:
        workspace.name
        workspace.documents

    provides:
        CreateDocument(member, workspace, title, content)
            when membership.can_write
        for document in workspace.documents:
            ViewDocument(member, document)
                when membership.can_read

    related:
        WorkspaceMemberManagement(workspace)
            when membership.can_admin
}

Key language features shown:

  • Recursive derived values (effective_permissions includes inherited)
  • Null-safe navigation (inherits_from?.effective_permissions ?? {})
  • Join entity lookup (WorkspaceMembership{user: actor, workspace: workspace})
  • Permission checks in requires clauses
  • String set membership with in operator
  • .add() and .remove() for set mutation in ensures clauses
  • not exists as an outcome (removes the entity)
  • Surfaces with role-based actors and permission-gated actions
  • related clause for cross-surface navigation

Pattern 3: Invitation to Resource

Demonstrates: Tokenised actions, permission levels, invitation lifecycle, guest vs member flows, surfaces

This pattern handles inviting users to collaborate on resources, whether they're existing users or not.

-- allium: 3
-- resource-invitation.allium

config {
    invitation_expiry: Duration = 7.days
}

------------------------------------------------------------
-- Enumerations
------------------------------------------------------------

enum Permission { view | edit | admin }

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity Resource {
    name: String
    owner: User

    -- Relationships
    shares: ResourceShare with resource = this
    invitations: ResourceInvitation with resource = this

    -- Projections
    active_shares: shares where status = active
    pending_invitations: invitations where status = pending
}

entity ResourceShare {
    resource: Resource
    user: User
    permission: Permission
    status: active | revoked
    created_at: Timestamp

    -- Derived
    can_view: permission in {view, edit, admin}
    can_edit: permission in {edit, admin}
    can_admin: permission = admin
    can_invite: permission in {edit, admin}    -- editors and admins can invite
}

entity ResourceInvitation {
    resource: Resource
    email: String
    permission: Permission
    invited_by: User
    created_at: Timestamp
    expires_at: Timestamp
    status: pending | accepted | declined | expired | revoked

    -- Derived
    is_valid: status = pending and expires_at > now
}

------------------------------------------------------------
-- Inviting
------------------------------------------------------------

rule InviteToResource {
    when: InviteToResource(inviter, resource, email, permission)

    let inviter_share = ResourceShare{resource: resource, user: inviter}
    let existing_invitation = ResourceInvitation{resource: resource, email: email}

    requires: inviter = resource.owner or inviter_share.can_invite
    requires: permission in {view, edit}    -- can't invite as admin unless owner
              or (permission = admin and inviter = resource.owner)
    requires: not exists ResourceShare{resource: resource, user: User{email: email}}
    requires: not exists existing_invitation or not existing_invitation.is_valid

    ensures: ResourceInvitation.created(
        resource: resource,
        email: email,
        permission: permission,
        invited_by: inviter,
        created_at: now,
        expires_at: now + config.invitation_expiry,
        status: pending
    )
    ensures: Email.created(
        to: email,
        template: resource_invitation,
        data: {
            resource: resource,
            inviter: inviter,
            permission: permission
        }
    )
}

------------------------------------------------------------
-- Accepting (existing user)
------------------------------------------------------------

rule AcceptInvitationExistingUser {
    when: ExistingUserAcceptsInvitation(invitation, user)

    requires: invitation.is_valid
    requires: user.email = invitation.email

    ensures: invitation.status = accepted
    ensures: ResourceShare.created(
        resource: invitation.resource,
        user: user,
        permission: invitation.permission,
        status: active,
        created_at: now
    )
    ensures: Notification.created(
        to: invitation.invited_by,
        template: invitation_accepted,
        data: { resource: invitation.resource, user: user }
    )
}

------------------------------------------------------------
-- Accepting (new user - triggers signup flow)
------------------------------------------------------------

rule AcceptInvitationNewUser {
    when: NewUserAcceptsInvitation(invitation, email, name, password)

    requires: invitation.is_valid
    requires: email = invitation.email
    requires: not exists User{email: email}

    ensures:
        let user = User.created(
            email: email,
            name: name,
            password_hash: hash(password),
            status: active
        )
        invitation.status = accepted
        ResourceShare.created(
            resource: invitation.resource,
            user: user,
            permission: invitation.permission,
            status: active,
            created_at: now
        )
        Notification.created(
            to: invitation.invited_by,
            template: invitation_accepted,
            data: { resource: invitation.resource, user: user }
        )
}

------------------------------------------------------------
-- Declining and expiring
------------------------------------------------------------

rule DeclineInvitation {
    when: DeclineInvitation(invitation)

    requires: invitation.is_valid

    ensures: invitation.status = declined
}

rule InvitationExpires {
    when: invitation: ResourceInvitation.expires_at <= now

    requires: invitation.status = pending

    ensures: invitation.status = expired
}

rule RevokeInvitation {
    when: RevokeInvitation(actor, invitation)

    let actor_share = ResourceShare{resource: invitation.resource, user: actor}

    requires: invitation.status = pending
    requires: actor = invitation.resource.owner or actor_share.can_admin

    ensures: invitation.status = revoked
}

------------------------------------------------------------
-- Managing shares
------------------------------------------------------------

rule ChangeSharePermission {
    when: ChangeSharePermission(actor, share, new_permission)

    let actor_share = ResourceShare{resource: share.resource, user: actor}

    requires: actor = share.resource.owner or actor_share.can_admin
    requires: share.user != share.resource.owner    -- can't change owner
    requires: share.status = active

    ensures: share.permission = new_permission
}

rule RevokeShare {
    when: RevokeShare(actor, share)

    let actor_share = ResourceShare{resource: share.resource, user: actor}

    requires: actor = share.resource.owner or actor_share.can_admin
    requires: share.user != share.resource.owner
    requires: share.status = active

    ensures: share.status = revoked
    ensures: Notification.created(
        to: share.user,
        template: access_revoked,
        data: { resource: share.resource }
    )
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface ResourceSharing {
    facing sharer: User

    context resource: Resource

    let share = ResourceShare{resource: resource, user: sharer}

    exposes:
        resource.active_shares
        resource.pending_invitations

    provides:
        InviteToResource(sharer, resource, email, permission)
            when sharer = resource.owner or share.can_invite
        for invitation in resource.pending_invitations:
            RevokeInvitation(sharer, invitation)
                when sharer = resource.owner or share.can_admin
        for s in resource.active_shares:
            ChangeSharePermission(sharer, s, new_permission)
                when sharer = resource.owner or share.can_admin
            RevokeShare(sharer, s)
                when sharer = resource.owner or share.can_admin

    @guarantee OwnerCannotBeRevoked
        -- The resource owner's access cannot be revoked or downgraded.
}

surface InvitationResponse {
    facing recipient: User

    context invitation: ResourceInvitation where email = recipient.email

    exposes:
        invitation.resource.name
        invitation.permission
        invitation.invited_by.name
        invitation.expires_at
        invitation.is_valid

    provides:
        ExistingUserAcceptsInvitation(invitation, recipient)
            when invitation.is_valid
        DeclineInvitation(invitation)
            when invitation.is_valid
}

Key language features shown:

  • Named enum (Permission) shared across ResourceShare and ResourceInvitation
  • Complex permission logic in requires
  • Distinct trigger names for different parameter shapes (ExistingUserAcceptsInvitation vs NewUserAcceptsInvitation)
  • Invitation lifecycle (pending → accepted/declined/expired/revoked)
  • Checking existence with exists keyword
  • Permission escalation prevention (can't invite as admin unless owner)
  • Surfaces for both resource owner and invitation recipient boundaries
  • Conditional provides with for iteration over collections

Pattern 4: Soft Delete & Restore

Demonstrates: Simple state machines, projections that filter deleted items, retention policies

This pattern implements soft delete where items appear deleted but can be restored within a retention period.

-- allium: 3
-- soft-delete.allium

config {
    retention_period: Duration = 30.days
}

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity Document {
    workspace: Workspace
    title: String
    content: String
    created_by: User
    created_at: Timestamp
    status: active | deleted
    deleted_at: Timestamp?
    deleted_by: User?

    -- Derived
    is_active: status = active
    retention_expires_at: deleted_at + config.retention_period
    can_restore: status = deleted and retention_expires_at > now
}

-- Extend Workspace to show how projections filter
entity Workspace {
    name: String

    -- Relationships
    all_documents: Document with workspace = this

    -- Projections (what users typically see)
    documents: all_documents where status = active
    deleted_documents: all_documents where status = deleted
    restorable_documents: all_documents where can_restore = true
}

------------------------------------------------------------
-- Rules
------------------------------------------------------------

rule DeleteDocument {
    when: DeleteDocument(actor, document)

    let membership = WorkspaceMembership{user: actor, workspace: document.workspace}

    requires: document.status = active
    requires: actor = document.created_by or membership.can_admin

    ensures: document.status = deleted
    ensures: document.deleted_at = now
    ensures: document.deleted_by = actor
}

rule RestoreDocument {
    when: RestoreDocument(actor, document)

    let membership = WorkspaceMembership{user: actor, workspace: document.workspace}

    requires: document.can_restore
    requires: actor = document.deleted_by or membership.can_admin

    ensures: document.status = active
    ensures: document.deleted_at = null
    ensures: document.deleted_by = null
}

rule PermanentlyDelete {
    when: PermanentlyDelete(actor, document)

    let membership = WorkspaceMembership{user: actor, workspace: document.workspace}

    requires: document.status = deleted
    requires: membership.can_admin

    ensures: not exists document    -- actually removed
}

rule RetentionExpires {
    when: document: Document.retention_expires_at <= now

    requires: document.status = deleted

    ensures: not exists document
}

------------------------------------------------------------
-- Bulk operations
------------------------------------------------------------

rule EmptyTrash {
    when: EmptyTrash(actor, workspace)

    let membership = WorkspaceMembership{user: actor, workspace: workspace}

    requires: membership.can_admin

    ensures:
        for d in workspace.deleted_documents:
            not exists d
}

rule RestoreAll {
    when: RestoreAllDeleted(actor, workspace)

    let membership = WorkspaceMembership{user: actor, workspace: workspace}

    requires: membership.can_admin

    ensures:
        for d in workspace.restorable_documents:
            d.status = active
            d.deleted_at = null
            d.deleted_by = null
}

Key language features shown:

  • status field with clear lifecycle
  • Nullable timestamps (deleted_at: Timestamp?)
  • Projections filtering by status (documents: all_documents where status = active)
  • Derived values using config (retention_expires_at: deleted_at + config.retention_period)
  • Temporal trigger for automatic cleanup (when: document: Document.retention_expires_at <= now)
  • not exists for permanent removal, as distinct from soft delete
  • Bulk operations with for iteration

Pattern 5: Notification Preferences & Digests

Demonstrates: Sum types for notification variants, user preferences affecting rule behaviour, digest batching, temporal triggers, surfaces

This pattern handles in-app notifications with user-controlled email preferences and digest batching. It uses sum types to model different notification kinds, each carrying its own contextual data rather than pre-computed strings.

-- allium: 3
-- notifications.allium
-- Elided types: Comment, Resource, Task, Permission, DayOfWeek
-- (defined in other patterns or your domain spec)

config {
    digest_window: Duration = 24.hours
}

------------------------------------------------------------
-- Enumerations
------------------------------------------------------------

enum EmailFrequency { immediately | daily_digest | never }

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity User {
    email: String
    name: String
    next_digest_at: Timestamp?

    -- Relationships
    notification_setting: NotificationSetting with user = this
    notifications: Notification with user = this

    -- Projections
    unread_notifications: notifications where status = unread
    pending_email_notifications: notifications where email_status = pending
    recent_pending_notifications: notifications where email_status = pending and created_at >= now - config.digest_window
}

entity NotificationSetting {
    user: User

    -- Per-type email preferences
    email_on_mention: EmailFrequency
    email_on_comment: EmailFrequency
    email_on_share: EmailFrequency
    email_on_assignment: EmailFrequency

    -- Global settings
    digest_enabled: Boolean
    digest_day_of_week: Set<DayOfWeek>    -- domain type; define as enum in your spec
}

------------------------------------------------------------
-- Notification Sum Type
------------------------------------------------------------

-- Base notification entity with shared fields
entity Notification {
    user: User
    created_at: Timestamp
    status: unread | read | archived
    email_status: pending | sent | skipped | digested
    kind: MentionNotification | ReplyNotification | ShareNotification |
          AssignmentNotification | SystemNotification

    -- Derived
    is_unread: status = unread
}

-- Someone @mentioned the user in a comment
variant MentionNotification : Notification {
    comment: Comment
    mentioned_by: User
}

-- Someone replied to the user's comment
variant ReplyNotification : Notification {
    reply: Comment              -- the new reply
    original_comment: Comment   -- the user's comment being replied to
    replied_by: User
}

-- Someone shared a resource with the user
variant ShareNotification : Notification {
    resource: Resource
    shared_by: User
    permission: Permission
}

-- Someone assigned a task to the user
variant AssignmentNotification : Notification {
    task: Task
    assigned_by: User
}

-- System-generated notification (catch-all for non-structured notifications)
variant SystemNotification : Notification {
    title: String
    body: String
    link: String?
}

------------------------------------------------------------
-- Supporting Entities
------------------------------------------------------------

entity DigestBatch {
    user: User
    notifications: Set<Notification>
    created_at: Timestamp
    sent_at: Timestamp?
    status: pending | sent | failed
}

------------------------------------------------------------
-- Creating notifications (type-specific rules)
------------------------------------------------------------

rule CreateMentionNotification {
    when: UserMentioned(user, comment, mentioned_by)

    let settings = user.notification_setting

    requires: user != mentioned_by    -- don't notify self

    ensures: MentionNotification.created(
        user: user,
        comment: comment,
        mentioned_by: mentioned_by,
        created_at: now,
        status: unread,
        email_status: if settings.email_on_mention = never: skipped else: pending
    )
}

rule CreateReplyNotification {
    when: CommentReplied(original_author, reply, original_comment)

    let settings = original_author.notification_setting

    requires: original_author != reply.author    -- don't notify self

    ensures: ReplyNotification.created(
        user: original_author,
        reply: reply,
        original_comment: original_comment,
        replied_by: reply.author,
        created_at: now,
        status: unread,
        email_status: if settings.email_on_comment = never: skipped else: pending
    )
}

rule CreateShareNotification {
    when: ResourceShared(user, resource, shared_by, permission)

    let settings = user.notification_setting

    requires: user != shared_by    -- don't notify self

    ensures: ShareNotification.created(
        user: user,
        resource: resource,
        shared_by: shared_by,
        permission: permission,
        created_at: now,
        status: unread,
        email_status: if settings.email_on_share = never: skipped else: pending
    )
}

rule CreateAssignmentNotification {
    when: TaskAssigned(user, task, assigned_by)

    let settings = user.notification_setting

    requires: user != assigned_by    -- don't notify self

    ensures: AssignmentNotification.created(
        user: user,
        task: task,
        assigned_by: assigned_by,
        created_at: now,
        status: unread,
        email_status: if settings.email_on_assignment = never: skipped else: pending
    )
}

rule CreateSystemNotification {
    when: SystemNotificationTriggered(user, title, body, link)

    ensures: SystemNotification.created(
        user: user,
        title: title,
        body: body,
        link: link,
        created_at: now,
        status: unread,
        email_status: pending
    )
}

------------------------------------------------------------
-- Immediate email sending
------------------------------------------------------------

rule SendImmediateEmail {
    when: notification: Notification.created

    let settings = notification.user.notification_setting
    let preference =
        if notification.kind = MentionNotification: settings.email_on_mention
        else if notification.kind = ReplyNotification: settings.email_on_comment
        else if notification.kind = ShareNotification: settings.email_on_share
        else if notification.kind = AssignmentNotification: settings.email_on_assignment
        else: immediately    -- system notifications send immediately by default

    requires: notification.email_status = pending
    requires: preference = immediately

    ensures: Email.created(
        to: notification.user.email,
        template: notification_immediate,
        data: { notification: notification }
    )
    ensures: notification.email_status = sent
}

------------------------------------------------------------
-- Reading notifications
------------------------------------------------------------

rule MarkAsRead {
    when: MarkNotificationRead(user, notification)

    requires: notification.user = user
    requires: notification.status = unread

    ensures: notification.status = read
}

rule MarkAllAsRead {
    when: MarkAllNotificationsRead(user)

    ensures:
        for n in user.unread_notifications:
            n.status = read
}

rule ArchiveNotification {
    when: ArchiveNotification(user, notification)

    requires: notification.user = user

    ensures: notification.status = archived
}

------------------------------------------------------------
-- Daily digest
------------------------------------------------------------

rule CreateDailyDigest {
    when: user: User.next_digest_at <= now

    requires: user.notification_setting.digest_enabled

    let pending = user.recent_pending_notifications

    requires: pending.count > 0

    ensures: DigestBatch.created(
        user: user,
        notifications: pending,
        created_at: now,
        status: pending
    )
    ensures:
        for n in pending:
            n.email_status = digested
    ensures: user.next_digest_at = next_digest_time(user)    -- black box; uses digest_day_of_week
}

rule SendDigest {
    when: batch: DigestBatch.created

    requires: batch.status = pending
    requires: batch.notifications.count > 0

    ensures: Email.created(
        to: batch.user.email,
        template: daily_digest,
        data: {
            notifications: batch.notifications,
            unread_count: batch.user.unread_notifications.count
        }
    )
    ensures: batch.status = sent
    ensures: batch.sent_at = now
}

------------------------------------------------------------
-- Preference updates
------------------------------------------------------------

rule UpdateNotificationPreferences {
    when: UpdatePreferences(user, preferences)

    let settings = user.notification_setting

    ensures: settings.email_on_mention = preferences.mention
    ensures: settings.email_on_comment = preferences.comment
    ensures: settings.email_on_share = preferences.share
    ensures: settings.email_on_assignment = preferences.assignment
    ensures: settings.digest_enabled = preferences.digest_enabled
    ensures: settings.digest_day_of_week = preferences.digest_days
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface NotificationCentre {
    facing user: User

    exposes:
        user.unread_notifications
        user.unread_notifications.count
        user.notifications

    provides:
        for notification in user.unread_notifications:
            MarkNotificationRead(user, notification)
        MarkAllNotificationsRead(user)
            when user.unread_notifications.count > 0
        for notification in user.notifications:
            ArchiveNotification(user, notification)

    related:
        NotificationPreferences(user)
}

surface NotificationPreferences {
    facing user: User

    context settings: NotificationSetting where user = user

    exposes:
        settings.email_on_mention
        settings.email_on_comment
        settings.email_on_share
        settings.email_on_assignment
        settings.digest_enabled
        settings.digest_day_of_week

    provides:
        UpdatePreferences(user, preferences)
}

Key language features shown:

  • Sum types: kind: MentionNotification | ReplyNotification | ... declares notification variants
  • Variant declarations: Each notification kind uses variant X : Notification syntax
  • Variant-specific creation rules: Each variant has its own creation rule with appropriate fields
  • Exhaustive kind checking: SendImmediateEmail handles all variants explicitly
  • Named enum (EmailFrequency) shared across preference fields
  • User preferences stored as entity
  • Temporal trigger for per-user digest scheduling (when: user: User.next_digest_at <= now)
  • Digest batching with temporal trigger
  • Surfaces with related clause linking notification centre to preferences

Why sum types here?

The previous approach used pre-computed title, body, and link strings:

Notification.created(
    type: mention,
    title: "{author} mentioned you",
    body: truncate(comment.body, 100),
    link: comment.parent.url
)

With sum types, each notification carries its actual entity references:

MentionNotification.created(
    comment: comment,
    mentioned_by: author
)

This is better because:

  1. Rich queries: "Show all notifications about this document" queries the actual relationships
  2. Type safety: Creating a MentionNotification requires a comment - you can't forget it
  3. Flexible rendering: Display logic can access full entity data, not just truncated strings
  4. Consistency: If a user's name changes, notification titles reflect the current name

Pattern 6: Usage Limits & Quotas

Demonstrates: Limit checks in requires, metered resources, plan tiers, overage handling, surfaces

This pattern handles SaaS usage limits: different plans have different quotas, and usage is tracked and enforced.

-- allium: 3
-- usage-limits.allium
-- Elided types: Feature (define as enum in your spec)

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity Plan {
    name: String                    -- e.g., "free", "pro", "enterprise"

    -- Limits (null = unlimited)
    max_documents: Integer?
    max_storage_bytes: Integer?
    max_team_members: Integer?
    max_api_requests_per_day: Integer?

    -- Features
    features: Set<Feature>          -- domain type; define in your spec

    -- Derived
    has_unlimited_documents: max_documents = null
    has_unlimited_storage: max_storage_bytes = null
    has_unlimited_members: max_team_members = null
}

entity Workspace {
    name: String
    owner: User
    plan: Plan
    api_key: String?

    -- Relationships
    documents: Document with workspace = this
    memberships: WorkspaceMembership with workspace = this
    usage: WorkspaceUsage with workspace = this

    -- Derived checks
    can_add_document: plan.has_unlimited_documents or documents.count < plan.max_documents
    can_add_member: plan.has_unlimited_members or memberships.count < plan.max_team_members
    can_use_feature(f): f in plan.features
}

entity WorkspaceUsage {
    workspace: Workspace
    storage_bytes_used: Integer
    api_requests_today: Integer
    next_reset_at: Timestamp

    -- Derived (null when plan has no limit)
    api_requests_remaining:
        workspace.plan.max_api_requests_per_day - api_requests_today
    has_api_quota: workspace.plan.max_api_requests_per_day != null
    is_over_api_quota: has_api_quota and api_requests_remaining <= 0
}

entity UsageEvent {
    workspace: Workspace
    type: document_created | document_deleted | storage_added |
          storage_removed | api_request | member_added | member_removed
    amount: Integer
    recorded_at: Timestamp
}

------------------------------------------------------------
-- Defaults
------------------------------------------------------------

default Plan free = {
    name: "free",
    max_documents: 10,
    max_storage_bytes: 100_000_000,    -- 100MB
    max_team_members: 3,
    max_api_requests_per_day: 100,
    features: { basic_editing }
}

default Plan pro = {
    name: "pro",
    max_documents: 1000,
    max_storage_bytes: 10_000_000_000,  -- 10GB
    max_team_members: 20,
    max_api_requests_per_day: 10000,
    features: { basic_editing, advanced_editing, api_access, integrations }
}

default Plan enterprise = {
    name: "enterprise",
    features: { basic_editing, advanced_editing, api_access, integrations,
                sso, audit_log, custom_branding }
    -- max_documents, max_storage_bytes, max_team_members,
    -- max_api_requests_per_day all null (unlimited)
}

------------------------------------------------------------
-- Enforcing limits
------------------------------------------------------------

rule CreateDocument {
    when: CreateDocument(user, workspace, title)

    requires: workspace.can_add_document

    ensures: Document.created(workspace: workspace, title: title, created_by: user)
    ensures: UsageEvent.created(
        workspace: workspace,
        type: document_created,
        amount: 1,
        recorded_at: now
    )
}

rule CreateDocumentLimitReached {
    when: CreateDocument(user, workspace, title)

    requires: not workspace.can_add_document

    ensures: UserInformed(
        user: user,
        about: limit_reached,
        data: {
            limit_type: documents,
            current: workspace.documents.count,
            max: workspace.plan.max_documents,
            upgrade_path: next_plan(workspace.plan)
        }
    )
}

rule AddTeamMember {
    when: AddMember(actor, workspace, new_member, role)

    requires: workspace.can_add_member
    requires: WorkspaceMembership{user: actor, workspace: workspace}.can_admin

    ensures: WorkspaceMembership.created(...)
    ensures: UsageEvent.created(
        workspace: workspace,
        type: member_added,
        amount: 1,
        recorded_at: now
    )
}

rule UseFeature {
    when: UseFeature(user, workspace, feature)

    requires: workspace.can_use_feature(feature)

    ensures: FeatureUsed(workspace: workspace, feature: feature, by: user)
}

rule UseFeatureNotAvailable {
    when: UseFeature(user, workspace, feature)

    requires: not workspace.can_use_feature(feature)

    ensures: UserInformed(
        user: user,
        about: feature_not_available,
        data: {
            feature: feature,
            available_on: plans_with_feature(feature)
        }
    )
}

------------------------------------------------------------
-- API rate limiting
------------------------------------------------------------

rule RecordApiRequest {
    when: ApiRequestReceived(workspace, endpoint)

    let usage = workspace.usage

    requires: not usage.is_over_api_quota

    ensures: usage.api_requests_today = usage.api_requests_today + 1
    ensures: UsageEvent.created(
        workspace: workspace,
        type: api_request,
        amount: 1,
        recorded_at: now
    )
}

rule ApiRateLimitExceeded {
    when: ApiRequestReceived(workspace, endpoint)

    let usage = workspace.usage

    requires: usage.is_over_api_quota

    ensures: ApiRequestRejected(
        workspace: workspace,
        reason: rate_limit_exceeded,
        data: { resets_at: usage.next_reset_at }
    )
}

rule ResetDailyApiUsage {
    when: usage: WorkspaceUsage.next_reset_at <= now

    requires: usage.api_requests_today > 0    -- prevents re-firing when already reset

    ensures: usage.api_requests_today = 0
    ensures: usage.next_reset_at = usage.next_reset_at + 1.day
}

------------------------------------------------------------
-- Plan changes
------------------------------------------------------------

rule UpgradePlan {
    when: UpgradePlan(workspace, new_plan)

    let old_plan = workspace.plan

    requires: new_plan.max_documents >= old_plan.max_documents
              or new_plan.has_unlimited_documents

    ensures: workspace.plan = new_plan
    ensures: Email.created(
        to: workspace.owner.email,
        template: plan_upgraded,
        data: { old_plan: old_plan, new_plan: new_plan }
    )
}

rule DowngradePlan {
    when: DowngradePlan(workspace, new_plan)

    let old_plan = workspace.plan

    -- Can only downgrade if under new plan's limits
    requires: workspace.documents.count <= new_plan.max_documents
              or new_plan.has_unlimited_documents
    requires: workspace.memberships.count <= new_plan.max_team_members
              or new_plan.has_unlimited_members
    requires: workspace.usage.storage_bytes_used <= new_plan.max_storage_bytes
              or new_plan.has_unlimited_storage

    ensures: workspace.plan = new_plan
    ensures: Email.created(
        to: workspace.owner.email,
        template: plan_downgraded,
        data: { old_plan: old_plan, new_plan: new_plan }
    )
}

rule DowngradeBlocked {
    when: DowngradePlan(workspace, new_plan)

    let over_documents =
        workspace.documents.count > new_plan.max_documents
        and not new_plan.has_unlimited_documents
    let over_members =
        workspace.memberships.count > new_plan.max_team_members
        and not new_plan.has_unlimited_members
    let over_storage =
        workspace.usage.storage_bytes_used > new_plan.max_storage_bytes
        and not new_plan.has_unlimited_storage

    requires: over_documents or over_members or over_storage

    ensures: UserInformed(
        user: workspace.owner,
        about: downgrade_blocked,
        data: {
            over_documents: over_documents,
            over_members: over_members,
            over_storage: over_storage
        }
    )
}

------------------------------------------------------------
-- Actors
------------------------------------------------------------

actor WorkspaceOwner {
    within: Workspace
    identified_by: User where this = within.owner
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface UsageDashboard {
    facing owner: WorkspaceOwner

    context workspace: Workspace

    exposes:
        workspace.plan
        workspace.documents.count
        workspace.plan.max_documents
        workspace.memberships.count
        workspace.plan.max_team_members
        workspace.usage.storage_bytes_used
        workspace.plan.max_storage_bytes
        workspace.usage.api_requests_today
        workspace.usage.api_requests_remaining

    provides:
        UpgradePlan(workspace, new_plan)
        DowngradePlan(workspace, new_plan)

    @guidance
        -- Show progress bars for usage against limits.
        -- Highlight when any resource is above 80% of its limit.
}

surface APIAccess {
    facing consumer: Workspace

    exposes:
        consumer.usage.api_requests_remaining
        consumer.plan.max_api_requests_per_day

    provides:
        ApiRequestReceived(consumer, endpoint)
            when not consumer.usage.is_over_api_quota

    @guarantee RateLimitEnforcement
        -- Requests beyond the daily limit receive HTTP 429 with
        -- reset time.
}

Key language features shown:

  • Plan definitions with limits
  • Derived boolean checks for limit enforcement (can_add_document, can_add_member)
  • requires checking limits before actions
  • Paired rules for success/failure cases
  • Usage tracking with events
  • Temporal trigger for daily reset (when: usage: WorkspaceUsage.next_reset_at <= now)
  • Plan upgrade/downgrade logic with let binding to capture pre-mutation state
  • Feature flags (can_use_feature(f))
  • Interaction surface for usage dashboard and API surface with rate limit guarantee

Pattern 7: Comments with Mentions

Demonstrates: Nested entities, parsing for mentions, cross-entity notifications, threading, surfaces

This pattern implements comments with @mentions, including mention parsing and notification generation.

-- allium: 3
-- comments.allium

------------------------------------------------------------
-- Entities
------------------------------------------------------------

external entity User {
    name: String
    is_admin: Boolean
}

external entity Commentable {
    -- Defined by the consuming spec (e.g., Document, Task, Project)
}

entity Comment {
    parent: Commentable
    reply_to: Comment?              -- null for top-level, set for replies
    author: User
    body: String
    created_at: Timestamp
    edited_at: Timestamp?
    status: active | deleted

    -- Relationships
    mentions: CommentMention with comment = this
    replies: Comment with reply_to = this
    reactions: CommentReaction with comment = this

    -- Projections
    active_replies: replies where status = active

    -- Derived
    is_reply: reply_to != null
    is_edited: edited_at != null
    mentioned_users: mentions -> user
    thread_depth: if is_reply: reply_to.thread_depth + 1 else: 0
}

-- Join entity for mentions
entity CommentMention {
    comment: Comment
    user: User
    notified: Boolean
}

entity CommentReaction {
    comment: Comment
    user: User
    emoji: String                   -- e.g., "👍", "❤️", "🎉"
    created_at: Timestamp
}

------------------------------------------------------------
-- Creating comments
------------------------------------------------------------

rule CreateComment {
    when: CreateComment(author, parent, body)

    let mentioned_usernames = parse_mentions(body)    -- black box: extracts @username
    let mentioned_users = users_with_usernames(mentioned_usernames)    -- black box lookup

    ensures:
        let comment = Comment.created(
            parent: parent,
            reply_to: null,
            author: author,
            body: body,
            created_at: now,
            status: active
        )
        for user in mentioned_users:
            CommentMention.created(
                comment: comment,
                user: user,
                notified: false
            )
}

rule CreateReply {
    when: CreateReply(author, parent_comment, body)

    let mentioned_usernames = parse_mentions(body)
    let mentioned_users = users_with_usernames(mentioned_usernames)    -- black box lookup

    requires: parent_comment.status = active
    requires: parent_comment.thread_depth < 3    -- limit nesting

    ensures:
        let comment = Comment.created(
            parent: parent_comment.parent,
            reply_to: parent_comment,
            author: author,
            body: body,
            created_at: now,
            status: active
        )
        for user in mentioned_users:
            CommentMention.created(
                comment: comment,
                user: user,
                notified: false
            )
}

------------------------------------------------------------
-- Notifications for mentions and replies
------------------------------------------------------------

-- Trigger the notification system when someone is mentioned
rule NotifyMentionedUser {
    when: mention: CommentMention.created

    requires: mention.user != mention.comment.author    -- don't notify self
    requires: not mention.notified

    ensures: mention.notified = true
    ensures: UserMentioned(
        user: mention.user,
        comment: mention.comment,
        mentioned_by: mention.comment.author
    )
}

-- Trigger the notification system when someone's comment receives a reply
rule NotifyCommentAuthorOfReply {
    when: comment: Comment.created

    let original_author = comment.reply_to?.author

    requires: comment.is_reply
    requires: original_author != null
    requires: original_author != comment.author    -- don't notify self
    requires: original_author not in comment.mentioned_users    -- avoid double notify

    ensures: CommentReplied(
        original_author: original_author,
        reply: comment,
        original_comment: comment.reply_to
    )
}

------------------------------------------------------------
-- Editing
------------------------------------------------------------

rule EditComment {
    when: EditComment(actor, comment, new_body)

    requires: actor = comment.author
    requires: comment.status = active

    let old_mentions = comment.mentioned_users
    let new_mentioned_usernames = parse_mentions(new_body)
    let new_mentioned_users = users_with_usernames(new_mentioned_usernames)    -- black box lookup
    let added_mentions = new_mentioned_users - old_mentions
    let removed_mentions = old_mentions - new_mentioned_users

    ensures: comment.body = new_body
    ensures: comment.edited_at = now

    -- Remove old mentions that are no longer present
    ensures:
        for user in removed_mentions:
            not exists CommentMention{comment, user}

    -- Add new mentions
    ensures:
        for user in added_mentions:
            CommentMention.created(
                comment: comment,
                user: user,
                notified: false
            )
}

------------------------------------------------------------
-- Deleting
------------------------------------------------------------

rule DeleteComment {
    when: DeleteComment(actor, comment)

    requires: actor = comment.author or actor.is_admin
    requires: comment.status = active

    ensures: comment.status = deleted
    -- Note: replies remain but show "deleted comment"
}

------------------------------------------------------------
-- Reactions
------------------------------------------------------------

rule AddReaction {
    when: AddReaction(user, comment, emoji)

    requires: comment.status = active
    requires: not exists CommentReaction{comment, user, emoji}

    ensures: CommentReaction.created(
        comment: comment,
        user: user,
        emoji: emoji,
        created_at: now
    )
}

rule RemoveReaction {
    when: RemoveReaction(user, comment, emoji)

    let reaction = CommentReaction{comment, user, emoji}

    requires: comment.status = active
    requires: exists reaction

    ensures: not exists reaction
}

rule ToggleReaction {
    when: ToggleReaction(user, comment, emoji)

    let existing = CommentReaction{comment, user, emoji}

    requires: comment.status = active

    ensures:
        if exists existing:
            not exists existing
        else:
            CommentReaction.created(
                comment: comment,
                user: user,
                emoji: emoji,
                created_at: now
            )
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

surface CommentThread {
    facing viewer: User

    context parent: Commentable

    let comments = Comments where parent = parent and status = active

    exposes:
        for comment in comments:
            comment.author.name
            comment.body
            comment.created_at
            comment.is_edited
            comment.active_replies
            comment.reactions

    provides:
        CreateComment(viewer, parent, body)
        for comment in comments:
            CreateReply(viewer, comment, body)
                when comment.thread_depth < 3
            EditComment(viewer, comment, new_body)
                when viewer = comment.author
            DeleteComment(viewer, comment)
                when viewer = comment.author or viewer.is_admin
            AddReaction(viewer, comment, emoji)
            RemoveReaction(viewer, comment, emoji)
                when exists CommentReaction{comment: comment, user: viewer, emoji: emoji}

    @guidance
        -- Show "edited" indicator when comment.is_edited.
        -- Show "deleted comment" placeholder for deleted replies
        -- rather than removing them from the thread.
}

Key language features shown:

  • Nested/recursive entities (comments with replies)
  • Entity creation triggers with binding (when: mention: CommentMention.created)
  • Black box functions (parse_mentions(), users_with_usernames())
  • Explicit let binding for created entities
  • Set operations (new_mentioned_users - old_mentions)
  • Depth limiting (thread_depth < 3)
  • Cross-pattern triggers: Emits UserMentioned and CommentReplied triggers that Pattern 5 handles
  • Avoiding double notifications (original_author not in comment.mentioned_users)
  • Toggle pattern with conditional ensures
  • Join entity with three keys (CommentReaction{comment, user, emoji})
  • Surface with role-conditional actions (author can edit, author or admin can delete)

Pattern 8: Integrating Library Specs

Demonstrates: External spec references with coordinates, configuration blocks, config parameter references, responding to external triggers, using external entities

Library specs are standalone specifications for common functionality: authentication providers, payment processors, email services. They define a contract that implementations must satisfy, and your application spec composes them in. Consuming specs can reference a library spec's config values as defaults for their own parameters, avoiding duplication when the values should track each other.

Example: OAuth Authentication

This example shows integrating a library OAuth spec into your application. The OAuth spec handles the authentication flow; your application responds to authentication events and manages application-level user state.

-- allium: 3
-- app-auth.allium

------------------------------------------------------------
-- External Spec References
------------------------------------------------------------

-- Reference the OAuth spec from the library
-- The coordinate is immutable (git SHA), ensuring reproducible specs
use "github.com/allium-specs/oauth2/af8e2c1d" as oauth

-- Configure the OAuth spec for our application
oauth/config {
    providers: { google, microsoft, github }
    session_duration: 24.hours
    refresh_window: 1.hour
    link_expiry: 15.minutes
}

------------------------------------------------------------
-- Application Entities
------------------------------------------------------------

-- Our application's User entity, linked to OAuth identities
entity User {
    email: String
    name: String
    avatar_url: String?
    status: active | suspended | deactivated
    created_at: Timestamp
    last_login_at: Timestamp?

    -- Relationship to OAuth sessions (from external spec)
    sessions: oauth/Session with user = this
    identities: oauth/Identity with user = this

    -- Projections
    active_sessions: sessions where status = active

    -- Derived
    is_authenticated: active_sessions.count > 0
    linked_providers: identities -> provider
}

-- Application-specific user preferences
entity UserPreferences {
    user: User
    theme: light | dark | system
    timezone: String
    locale: String
}

------------------------------------------------------------
-- Responding to OAuth Events
------------------------------------------------------------

-- When a user authenticates for the first time, create our User entity
rule CreateUserOnFirstLogin {
    when: oauth/AuthenticationSucceeded(identity, session)

    requires: not exists User{email: identity.email}

    ensures:
        let user = User.created(
            email: identity.email,
            name: identity.display_name,
            avatar_url: identity.avatar_url,
            status: active,
            created_at: now,
            last_login_at: now
        )
        -- Link the OAuth identity to our user
        identity.user = user
        session.user = user
        -- Create default preferences
        UserPreferences.created(
            user: user,
            theme: system,
            timezone: identity.timezone ?? "UTC",
            locale: identity.locale ?? "en"
        )
        Email.created(
            to: user.email,
            template: welcome,
            data: { user: user, provider: identity.provider }
        )
}

-- When an existing user logs in, update last login
rule UpdateUserOnLogin {
    when: oauth/AuthenticationSucceeded(identity, session)

    let user = User{email: identity.email}

    requires: exists user
    requires: user.status = active

    ensures: user.last_login_at = now
    ensures: session.user = user
}

-- Block login for suspended users
rule BlockSuspendedUserLogin {
    when: oauth/AuthenticationSucceeded(identity, session)

    let user = User{email: identity.email}

    requires: exists user
    requires: user.status = suspended

    ensures: session.status = revoked
    ensures: UserInformed(
        user: user,
        about: account_suspended,
        data: { contact: "support@example.com" }
    )
}

-- When OAuth session expires, we might want to notify
rule NotifySessionExpiring {
    when: session: oauth/Session.status transitions_to expiring

    let user = session.user

    requires: user != null

    ensures: UserInformed(
        user: user,
        about: session_expiring,
        data: { time_remaining: session.time_remaining }
    )
}

-- Audit logging for security events
rule AuditLogout {
    when: oauth/SessionTerminated(session, reason)

    let user = session.user

    requires: user != null

    ensures: AuditLog.created(
        user: user,
        event: logout,
        reason: reason,
        timestamp: now,
        metadata: { provider: session.provider, session_start: session.created_at }
    )
}

------------------------------------------------------------
-- Application Actions Using OAuth
------------------------------------------------------------

rule LinkAdditionalProvider {
    when: LinkProvider(user, provider)

    requires: user.status = active
    requires: provider not in user.linked_providers

    -- Trigger the OAuth flow from the library spec
    ensures: oauth/InitiateAuthentication(
        provider: provider,
        intent: link_account,
        existing_user: user
    )
}

rule UnlinkProvider {
    when: UnlinkProvider(user, provider)

    let identity = oauth/Identity{user, provider}

    requires: user.status = active
    requires: exists identity
    requires: user.linked_providers.count > 1    -- must keep at least one

    ensures: not exists identity
    ensures: AuditLog.created(
        user: user,
        event: provider_unlinked,
        timestamp: now,
        metadata: { provider: provider }
    )
}

Example: Payment Processing

This example shows integrating a payment processor spec for subscription billing.

-- allium: 3
-- billing.allium

------------------------------------------------------------
-- External Spec References
------------------------------------------------------------

use "github.com/allium-specs/stripe-billing/b2c4e6f8" as stripe

stripe/config {
    currency: USD
    tax_calculation: automatic
    proration: create_prorations
    trial_period: 14.days
}

config {
    trial_period: Duration = stripe/config.trial_period
    extended_trial: Duration = stripe/config.trial_period * 2
    trial_reminder_lead: Duration = 3.days
}

------------------------------------------------------------
-- Application Entities
------------------------------------------------------------

entity Organisation {
    name: String
    owner: User
    billing_portal_url: String?

    -- Link to Stripe customer (from external spec)
    stripe_customer: stripe/Customer?

    -- Relationships
    subscription: Subscription with organisation = this
    invoices: stripe/Invoice with stripe_customer = this

    -- Derived
    is_paying: subscription?.status = active
    has_payment_method: stripe_customer?.default_payment_method != null
}

entity Subscription {
    organisation: Organisation
    plan: Plan
    status: trialing | active | past_due | cancelled | expired
    started_at: Timestamp
    trial_ends_at: Timestamp?
    current_period_ends_at: Timestamp
    trial_reminder_sent: Boolean

    -- Link to Stripe subscription
    stripe_subscription: stripe/Subscription?

    -- Derived
    is_trial: status = trialing
    days_until_renewal: current_period_ends_at - now
}

------------------------------------------------------------
-- Responding to Payment Events
------------------------------------------------------------

-- When Stripe confirms payment, activate or renew subscription
rule ActivateOnPaymentSuccess {
    when: stripe/PaymentSucceeded(invoice)

    let customer = invoice.customer
    let org = Organisation{stripe_customer: customer}
    let sub = org.subscription

    requires: exists org
    requires: sub.status in {trialing, past_due}

    ensures: sub.status = active
    ensures: sub.current_period_ends_at = invoice.period_end
    ensures: Email.created(
        to: org.owner.email,
        template: payment_confirmed,
        data: { amount: invoice.amount, next_billing: invoice.period_end }
    )
}

-- Handle failed payments
rule HandlePaymentFailure {
    when: stripe/PaymentFailed(invoice, failure_reason)

    let customer = invoice.customer
    let org = Organisation{stripe_customer: customer}
    let sub = org.subscription

    requires: exists org

    ensures: sub.status = past_due
    ensures: Email.created(
        to: org.owner.email,
        template: payment_failed,
        data: {
            reason: failure_reason,
            retry_date: invoice.next_payment_attempt,
            update_payment_url: org.billing_portal_url
        }
    )
    ensures: UserInformed(
        user: org.owner,
        about: payment_failed,
        data: { reason: failure_reason }
    )
}

-- When trial is ending, remind user
rule TrialEndingReminder {
    when: sub: Subscription.trial_ends_at - config.trial_reminder_lead <= now

    requires: sub.status = trialing
    requires: not sub.trial_reminder_sent

    let org = sub.organisation

    ensures: sub.trial_reminder_sent = true
    ensures: Email.created(
        to: org.owner.email,
        template: trial_ending,
        data: {
            days_remaining: config.trial_reminder_lead,
            plan: sub.plan,
            has_payment_method: org.has_payment_method
        }
    )
}

-- Respond to subscription cancellation from Stripe
rule HandleSubscriptionCancelled {
    when: stripe/SubscriptionCancelled(stripe_sub, reason)

    let sub = Subscription{stripe_subscription: stripe_sub}
    let org = sub.organisation

    requires: exists sub

    ensures: sub.status = cancelled
    ensures: Email.created(
        to: org.owner.email,
        template: subscription_cancelled,
        data: { reason: reason, access_until: sub.current_period_ends_at }
    )
    ensures: AuditLog.created(
        user: org.owner,
        event: subscription_cancelled,
        timestamp: now,
        metadata: { reason: reason, plan: sub.plan.name }
    )
}

------------------------------------------------------------
-- Application Actions Using Stripe
------------------------------------------------------------

rule StartSubscription {
    when: StartSubscription(org, plan)

    requires: org.subscription = null or org.subscription.status in {cancelled, expired}
    requires: org.stripe_customer != null
    requires: org.has_payment_method

    ensures: stripe/CreateSubscription(
        customer: org.stripe_customer,
        price: plan.stripe_price_id,
        trial_period: if plan.has_trial: stripe/config.trial_period else: null
    )
}

rule ChangePlan {
    when: ChangePlan(org, new_plan)

    let sub = org.subscription

    requires: sub.status = active
    requires: new_plan != sub.plan

    ensures: stripe/UpdateSubscription(
        subscription: sub.stripe_subscription,
        new_price: new_plan.stripe_price_id
    )
    ensures: sub.plan = new_plan
}

rule CancelSubscription {
    when: CancelSubscription(org, reason)

    let sub = org.subscription

    requires: sub.status in {active, trialing}

    ensures: stripe/CancelSubscription(
        subscription: sub.stripe_subscription,
        at_period_end: true    -- access continues until paid period ends
    )
    ensures: AuditLog.created(
        user: org.owner,
        event: cancellation_requested,
        timestamp: now,
        metadata: { reason: reason }
    )
}

Key language features shown:

  • External spec references with immutable coordinates (use "github.com/.../abc123" as alias)
  • Configuration blocks for external specs (oauth/config { ... })
  • Config parameter references as defaults (trial_period: Duration = stripe/config.trial_period)
  • Expression-form defaults derived from library config (extended_trial: Duration = stripe/config.trial_period * 2)
  • Responding to external triggers (when: oauth/AuthenticationSucceeded(...))
  • Trigger emissions for cross-pattern notification (UserInformed(...))
  • Responding to external state transitions (when: session: oauth/Session.status transitions_to expiring)
  • Using external entities (oauth/Session, stripe/Customer)
  • Linking application entities to external entities (stripe_customer: stripe/Customer?)
  • Triggering external actions (ensures: stripe/CreateSubscription(...))
  • Qualified names throughout (oauth/Session, stripe/config.trial_period)

Library Spec Design Principles

When creating or choosing library specs:

  1. Immutable coordinates: Always use content-addressed references (git SHAs), never floating versions
  2. Configuration over convention: Library specs should expose configuration for anything that might vary between applications
  3. Observable triggers: Library specs should emit triggers for all significant events so consuming specs can respond
  4. Minimal coupling: Library specs shouldn't depend on your application entities - the linkage goes one way
  5. Clear boundaries: The library spec handles its domain (OAuth flow, payment processing); your spec handles application concerns (user creation, access control)

Pattern 9: Framework Integration Contract

Demonstrates: Contract declarations, expression-bearing invariants, contracts: clause with demands/fulfils, programmatic surfaces, typed signatures

This pattern specifies the contract between an event-sourcing framework and its domain modules. The framework demands that each module supply a deterministic evaluation function; in return, the surface fulfils event submission and state snapshot services. Unlike user-facing surfaces that use exposes and provides, framework-to-module boundaries use a contracts: clause with demands and fulfils to describe programmatic obligations. Contracts are declared at module level so they can be reused across surfaces or referenced from other specs.

-- allium: 3
-- event-sourcing-integration.allium

------------------------------------------------------------
-- Value Types
------------------------------------------------------------

value EntityKey {
    kind: String
    id: String
}

value EventOutcome {
    entity_key: EntityKey
    new_state: ByteArray
    side_effects: List<SideEffect>
}

value SideEffect {
    kind: emit_event | schedule_timeout | request_snapshot
    payload: ByteArray
}

value SnapshotRequest {
    entity_key: EntityKey
    as_of: Timestamp
}

value Snapshot {
    entity_key: EntityKey
    state: ByteArray
    version: Integer
    taken_at: Timestamp
}

------------------------------------------------------------
-- Contracts
------------------------------------------------------------

contract DeterministicEvaluation {
    evaluate: (event_name: String, payload: ByteArray, current_state: ByteArray) -> EventOutcome

    @invariant Determinism
        -- For identical inputs (event_name, payload, current_state),
        -- evaluate must produce byte-identical EventOutcome values
        -- across all instances and invocations.

    @invariant Purity
        -- evaluate must not perform I/O, read the system clock,
        -- access mutable state outside its arguments, or depend
        -- on the order of previous invocations.

    @invariant TotalFunction
        -- evaluate must return a valid EventOutcome for every
        -- combination of registered event_name, well-formed payload
        -- and current_state. It must not throw or fail to terminate.

    @guidance
        -- Implementations should avoid allocating during evaluation
        -- where possible, as the framework may invoke evaluate
        -- at high frequency during replay.
}

contract EventSubmitter {
    submit: (idempotency_key: String, event_name: String, payload: ByteArray) -> EventSubmission

    @invariant AtMostOnceProcessing
        -- Within the submission TTL window (config.submission_ttl),
        -- a given idempotency key is accepted at most once.
        -- Duplicate submissions are rejected.

    @invariant OrderPreservation
        -- Events submitted by a single module are processed in
        -- submission order. No ordering guarantee exists across
        -- modules.
}

contract StateSnapshots {
    request_snapshot: (entity_key: EntityKey) -> Snapshot
    get_snapshot: (request: SnapshotRequest) -> Snapshot?

    @invariant SnapshotConsistency
        -- A snapshot reflects the state after applying all events
        -- up to and including the snapshot's version number.
        -- No partial application.
}

------------------------------------------------------------
-- Entities
------------------------------------------------------------

entity DomainModule {
    name: String
    version: String
    status: registered | active | suspended

    -- Relationships
    event_types: EventTypeRegistration with module = this

    -- Projections
    active_event_types: event_types where status = active
}

entity EventTypeRegistration {
    module: DomainModule
    event_name: String
    schema_hash: String
    status: active | deprecated
    registered_at: Timestamp
}

entity EventSubmission {
    module: DomainModule
    idempotency_key: String
    event_name: String
    payload: ByteArray
    status: pending | accepted
    submitted_at: Timestamp
    processed_at: Timestamp?

    invariant PayloadWithinLimit { length(payload) <= config.max_payload_bytes }
}

------------------------------------------------------------
-- Config
------------------------------------------------------------

config {
    submission_ttl: Duration = 24.hours
    max_payload_bytes: Integer = 1_000_000
}

------------------------------------------------------------
-- Rules
------------------------------------------------------------

rule RegisterModule {
    when: RegisterModule(module_name, version, event_types)

    requires: not exists DomainModule{name: module_name}

    ensures:
        let module = DomainModule.created(
            name: module_name,
            version: version,
            status: registered
        )
        for event_name in event_types:
            EventTypeRegistration.created(
                module: module,
                event_name: event_name,
                schema_hash: hash(event_name + version),
                status: active,
                registered_at: now
            )
}

rule ActivateModule {
    when: module: DomainModule.status becomes registered

    requires: module.event_types.count > 0

    ensures: module.status = active
}

rule SubmitEvent {
    when: SubmitEvent(module, idempotency_key, event_name, payload)

    let existing = EventSubmission{module: module, idempotency_key: idempotency_key}

    requires: module.status = active
    requires: not exists existing
    requires: exists EventTypeRegistration{module: module, event_name: event_name, status: active}
    requires: length(payload) <= config.max_payload_bytes

    ensures: EventSubmission.created(
        module: module,
        idempotency_key: idempotency_key,
        event_name: event_name,
        payload: payload,
        status: pending,
        submitted_at: now
    )
}

rule ProcessSubmission {
    when: submission: EventSubmission.status becomes pending

    ensures: submission.status = accepted
    ensures: submission.processed_at = now
}

rule ExpireOldSubmissions {
    when: submission: EventSubmission.submitted_at + config.submission_ttl <= now

    requires: submission.status in {pending, accepted}

    ensures: not exists submission
}

rule DeprecateEventType {
    when: DeprecateEventType(module, event_name)

    let registration = EventTypeRegistration{module: module, event_name: event_name}

    requires: exists registration
    requires: registration.status = active

    ensures: registration.status = deprecated
}

rule SuspendModule {
    when: SuspendModule(admin, module, reason)

    requires: module.status = active

    ensures: module.status = suspended
    ensures: AuditLog.created(
        event: module_suspended,
        timestamp: now,
        metadata: { module: module.name, reason: reason, by: admin }
    )
}

rule ReactivateModule {
    when: ReactivateModule(admin, module)

    requires: module.status = suspended
    requires: module.event_types.count > 0

    ensures: module.status = active
}

------------------------------------------------------------
-- Actor Declarations
------------------------------------------------------------

actor FrameworkRuntime {
    identified_by: DomainModule where status = active
}

------------------------------------------------------------
-- Surfaces
------------------------------------------------------------

-- User-facing surface for module administration
surface ModuleAdministration {
    facing admin: User

    exposes:
        for module in DomainModules:
            module.name
            module.version
            module.status
            module.active_event_types.count

    provides:
        RegisterModule(module_name, version, event_types)
        for module in DomainModules where status = active:
            SuspendModule(admin, module, reason)
            for registration in module.active_event_types:
                DeprecateEventType(module, registration.event_name)
        for module in DomainModules where status = suspended:
            ReactivateModule(admin, module)
}

-- Programmatic surface: the framework-to-module integration contract
surface EventSourcingIntegration {
    facing runtime: FrameworkRuntime

    context module: DomainModule where status = active

    contracts:
        demands DeterministicEvaluation
        fulfils EventSubmitter
        fulfils StateSnapshots

    @guarantee ModuleBoundaryIsolation
        -- Events and state from one module are never visible to
        -- another module's evaluate function. Cross-module
        -- communication happens only through side effects processed
        -- by the framework.
}

Key language features shown:

  • contract declarations at module level for reuse across surfaces
  • Surface contracts: clause with demands/fulfils direction markers (demands DeterministicEvaluation, fulfils EventSubmitter) without repeating signatures or invariants
  • Expression-bearing invariant Name { expression } on entities (PayloadWithinLimit on EventSubmission)
  • Prose-only @invariant Name inside contracts for properties that cannot be expressed as a single boolean expression
  • @guarantee Name at surface level, distinct from contract-scoped invariants (boundary-wide vs contract-scoped assertions)
  • @guidance inside a contract for non-normative implementation advice
  • Mixed surface: ModuleAdministration uses traditional exposes/provides for human actors; EventSourcingIntegration uses contracts: clause for programmatic integration
  • Actor declaration for a code-level party (FrameworkRuntime identified by an active module)

When to use contracts

Use contract declarations when the boundary is between code and code rather than between a user and an application. All contracts are declared at module level and referenced in surfaces via a contracts: clause with demands/fulfils direction markers. Common scenarios:

  • Framework-to-plugin contracts: the framework demands evaluation logic, fulfils lifecycle services
  • Service-to-adapter boundaries: the service demands a storage adapter, fulfils a query interface
  • Cross-context integration: one bounded context demands event handlers, fulfils event streams
  • SDK contracts: the SDK demands configuration and callbacks, fulfils client operations

Do not use contracts for user-facing surfaces. If the external party is a person interacting through a UI, use exposes (what they see) and provides (what actions they can take). Contracts describe what code must implement, not what users can do.

Contracts vs provides

provides: lists actions that an actor can invoke, each corresponding to a rule's external stimulus trigger. fulfils ContractName in a contracts: clause declares a set of typed operations that the surface owner supplies to the counterpart as an API. The distinction:

  • provides: SubmitEvent(module, key, name, payload) — an action the actor triggers; a rule fires in response
  • fulfils EventSubmitter — a typed operation set the surface makes available, defined in a contract declaration; the implementation is the surface owner's responsibility

Both describe things the surface supplies, but provides connects to the rule system while fulfils references a programmatic contract with typed signatures and invariants.

Invariant vs guarantee

@guarantee asserts a property of the surface boundary as a whole. invariant asserts a property scoped to the operations within a specific contract.

Invariants come in two forms. Expression-bearing invariants carry a boolean expression that can be checked mechanically. Prose invariants describe properties that require human or LLM judgement.

-- Expression-bearing invariant on an entity
entity EventSubmission {
    ...
    invariant PayloadWithinLimit { length(payload) <= config.max_payload_bytes }
}

-- Prose invariant inside a contract
contract DeterministicEvaluation {
    @invariant Purity
        -- evaluate must not perform I/O or access mutable state.
}

-- Surface-level guarantee: applies across the entire boundary
@guarantee ModuleBoundaryIsolation
    -- Events from one module are never visible to another module.

Use @guarantee for cross-cutting properties that span the whole surface. Use invariant for properties tied to specific operations within a contract, or for entity-level assertions.


Using These Patterns

Composition

Patterns can be composed. For example, a complete document collaboration spec might use:

use "./rbac.allium" as rbac
use "./soft-delete.allium" as trash
use "./comments.allium" as comments
use "./notifications.allium" as notify

entity Document {
    workspace: Workspace
    title: String
    content: String
    status: active | deleted
    deleted_at: Timestamp?
    deleted_by: User?

    -- From comments pattern
    comments: comments/Comment with document = this

    -- From soft-delete pattern
    retention_expires_at: deleted_at + trash/config.retention_period
    can_restore: status = deleted and retention_expires_at > now
    ...
}

-- Document actions require RBAC checks
rule EditDocument {
    when: EditDocument(user, document, content)

    let share = rbac/ResourceShare{resource: document, user: user}

    requires: share.can_edit
    ...
}

Adaptation

Patterns are starting points. When applying:

  1. Rename to match your domain (User → Member, Document → Note)
  2. Adjust timeouts and limits to your context
  3. Remove unused states or rules
  4. Extend with domain-specific behaviour
  5. Compose multiple patterns for richer functionality

Anti-Patterns

When using patterns, avoid:

  • Over-engineering: Don't include reaction system if you don't need reactions
  • Premature abstraction: Start concrete, extract patterns when you see repetition
  • Pattern worship: If the pattern doesn't fit, adapt it or write something custom
  • Ignoring context: A free tier pattern that makes sense for B2C may not fit B2B