@ — Canned Expression Directive
Named aliases for pre-written expressions stored in the database. A canned expression resolves to its stored body — which may itself contain BizFirst wildcards that are evaluated via multi-pass resolution.
| Property | Value |
|---|---|
| Directive name | @ (AtSign prefix) |
| Required isolation level | Safe (inherited from stored expression) |
| Project | Catalog.Services |
| Data source | Expression_CannedExpressions table via ICannedExpressionStore |
| Syntax form | {@ @AliasName } or bare @AliasName in supporting contexts |
Syntax
{@ $ctx.tenant.currency } → evaluated to "EUR"Resolution Order
When resolving @AliasName, the system searches in order and uses the first match:
- TenantID = current tenant, AppID = current app, Name = alias
- TenantID = current tenant, AppID = NULL (tenant-wide), Name = alias
- TenantID = NULL (platform-wide), AppID = NULL, Name = alias
This allows platform defaults to be overridden per-tenant or per-app without changing workflow definitions.
Database Schema
CREATE TABLE Expression_CannedExpressions (
ID INT IDENTITY,
TenantID INT NULL, -- NULL = platform-wide default
AppDomainID INT NULL, -- NULL = tenant-wide (all apps)
Name NVARCHAR(200) NOT NULL, -- the alias (e.g. "TenantCurrency")
Body NVARCHAR(MAX) NOT NULL, -- expression text (may contain wildcards)
Description NVARCHAR(500) NULL,
IsActive BIT DEFAULT 1,
CreatedOn DATETIME NOT NULL,
CreatedBy INT NOT NULL,
LastModifiedOn DATETIME NULL,
LastModifiedBy INT NULL,
-- ... full standard audit columns
CONSTRAINT UQ_CannedExpr UNIQUE (TenantID, AppDomainID, Name)
)
Multi-Pass Resolution
Canned expressions are the primary driver of multi-pass resolution. When a canned body itself contains BizFirst wildcards, those wildcards are resolved in subsequent passes:
Pass 1: {@ @GreetingLine }
↓ loads body: "Dear {@ $ctx.user.firstName }, welcome to {@ $ctx.tenant.name }"
Pass 2: wildcards inside the body are evaluated
↓ result: "Dear Alice, welcome to Acme Corp"
@A again, the VisitedKeys set catches the cycle and raises CyclicReference rather than looping infinitely.
Complex Scenarios
Scenario 1 — Tenant-Specific Legal Disclaimer
Each tenant has a different legal footer. A single workflow uses one canned alias — each tenant's record returns their own text:
// Platform default (TenantID = NULL):
@LegalDisclaimer → "This document is generated automatically. For queries contact support@bizfirst.ai."
// Acme Corp override (TenantID = 5):
@LegalDisclaimer → "Acme Corp. Registered in Ireland. Company No. 123456. VAT IE9876543A."
// Usage in email template — same workflow, different output per tenant:
{@ @LegalDisclaimer }
Scenario 2 — Composed Expression from Multiple Canned Parts
Build a reusable email greeting from several canned pieces, each independently overridable:
// @GreetingLine body: "Dear {@ $ctx.user.firstName },"
// @SignatureLine body: "{@ $ctx.user.name }\n{@ $ctx.user.department } Manager"
// @TenantSupport body: "{@ $ctx.tenant.name } Support — {@ $ctx.env.SUPPORT_EMAIL }"
// Email node:
{@ @GreetingLine }
Your payslip for {@ $ctx.now @date:MMMM yyyy } is attached.
Kind regards,
{@ @SignatureLine }
---
{@ @TenantSupport }
Scenario 3 — Shared Validation Rule
A canned expression encapsulates a reusable business rule used across multiple workflows:
// @IsHighValueTransaction body:
// "{@ $js`return parseFloat(context.input.current.amount) > 50000` }"
// Used in multiple workflows — change the threshold in ONE place:
{@ @IsHighValueTransaction }
// IfCondition: true → route to finance approval; false → auto-approve
// When threshold changes from 50000 to 75000:
// Update ONE DB record; all workflows using @IsHighValueTransaction update instantly.
Scenario 4 — Nested Canned Expressions
A canned expression that references another canned expression — resolved recursively via multi-pass:
// @AuditHeader body:
// "[{@ $ctx.now }] Tenant: {@ @TenantRef } | Exec: {@ $exec.executionId }"
// @TenantRef body:
// "{@ $ctx.tenant.id }:{@ $ctx.tenant.name }"
// Usage:
{@ @AuditHeader }
// Pass 1: expands @AuditHeader
// Pass 2: expands $ctx.now, @TenantRef, $exec.executionId
// Pass 3: expands $ctx.tenant.id, $ctx.tenant.name (from @TenantRef body)
// Result: "[2024-03-15T10:22:01Z] Tenant: 5:Acme Corp | Exec: e8f23a1b-..."
Scenario 5 — App-Specific Override for Multi-App Tenant
A tenant has two apps (Payroll UK, Payroll IE). The same alias resolves differently per app:
// @PayPeriodLabel entries:
// TenantID=5, AppDomainID=12 (Payroll UK): "Tax Week"
// TenantID=5, AppDomainID=15 (Payroll IE): "Pay Period"
// TenantID=NULL, AppDomainID=NULL (default): "Period"
// Same workflow runs in both apps:
{@ @PayPeriodLabel }: {@ $input.current.periodStart } – {@ $input.current.periodEnd }
// UK app → "Tax Week: 2024-04-06 – 2024-04-12"
// IE app → "Pay Period: 2024-04-01 – 2024-04-30"
Scenario 6 — REST API Management of Canned Expressions
Canned expressions are managed via REST API — enabling admin UIs and CI/CD pipeline updates:
// List all tenant-scoped canned expressions
GET /api/expressions/canned?tenantId=5
// Create a new one
POST /api/expressions/canned
{
"tenantId": 5,
"appDomainId": null,
"name": "SupportEmail",
"body": "{@ $ctx.env.SUPPORT_EMAIL @default:support@bizfirst.ai }",
"description": "Tenant support email address, falls back to platform default"
}
// Preview resolution without executing a workflow
POST /api/expressions/canned/preview
{
"name": "SupportEmail",
"tenantId": 5
}
// Returns: { "resolved": "helpdesk@acme.com", "passCount": 2 }
Common Errors
| Error | Cause | Fix |
|---|---|---|
PathNotFound: @MyAlias | No matching record in Expression_CannedExpressions for this tenant/app/name | Create the entry via admin UI or REST API; check spelling (case-sensitive) |
CyclicReference: @MyAlias | Canned expression body references itself directly or indirectly | Break the cycle by creating intermediate variables; VisitedKeys detects the loop |
DepthLimitExceeded | Deep chain of nested canned expressions exceeds 10 passes | Flatten nested canned expressions; increase depth limit with caution |
| Wrong tenant's value returned | Resolution order matched a broader-scoped record unexpectedly | Add a more specific record (TenantID + AppDomainID) to override the fallback |