mirror of
https://github.com/juxt/allium.git
synced 2026-09-14 13:56:44 +08:00
2951 lines
86 KiB
Markdown
2951 lines
86 KiB
Markdown
# 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
|