Blueprint standard
1. Blueprint JSON Structure
Every blueprint MUST include ALL top-level sections (empty arrays are acceptable):
{ "name": "Application Name", "description": "What this application does", "category": "itsm|hr|finance|operations|custom", "$schema": "https://flowlakes.com/blueprint/v2.0", "schema_version": "2.0.0", "system_tables": ["users", "groups"], "metadata": {}, "choices": [], "tables": [], "relationships": [], "rules": [], "workflows": [], "notifications": [], "approvals": [], "dashboards": [], "forms": [], "navigation": { "modules": [] }, "roles": [], "skills": [], "agents": [], "session_mappings": [], "doors": [], "table_notes": [], "detection_rules": [], "action_buttons": [], "service_hub_items": [], "seed_data": {}, "demo_data": {}}metadata (required)
{ "name": "Same as top-level name", "slug": "app_slug", "description": "Same as top-level description", "category": "itsm", "version": "3.0.0", "tags": ["itsm", "service-desk"], "author": "FlowLakes", "organization": "FlowLakes Inc.", "license": "MIT", "is_public": true, "status": "published", "readme": "Human-readable description of what this blueprint provides."}Rule: name, description, category MUST appear at both top-level AND inside metadata.
metadata.status (optional, the lifecycle word)
One of draft, published, deprecated, archived (BlueprintStatus).
Absent means published, so every file written before this key means what it
meant. The marketplace listing serves published only, so this is how a
blueprint is taken OFF the catalog: set deprecated and empty its sections.
An installed tenant learns that a component is gone on its next upgrade. An unrecognised word
REFUSES the load by name and the blueprint is not seeded at all: published
is the permissive value, so defaulting to it would put a withdrawn blueprint
back on every tenant’s marketplace.
metadata.visibility (optional, the rollout-stage word)
visibility is the ONE fact that decides who sees a published blueprint in
a Marketplace. It carries the application’s rollout stage (Hidden, Testing,
Available, Default).
Absent defaults to public unless metadata.control_plane_only is
true, so every file written before this key means what it meant.
| Value | Rollout stage | Who sees it |
|---|---|---|
public (default) | Available | Every tenant’s Marketplace lists it. |
staged | Testing | Staged: visible to FlowLakes for testing before general release. Installed tenants still receive its upgrades, and a later flip to public reaches them. |
control_plane | Hidden | Used only by FlowLakes itself. It is never listed in a tenant Marketplace. |
tenant_fork | not a rollout stage | A tenant’s own fork of a blueprint (app_studio’s “fork for customization”); always visible to the forking tenant, never listed for anyone else. |
Moving an application from staged to public is a
PATCH or the next MINOR: flip this one field.
An application already installed from a staged row keeps working
regardless of this field (the installed-modules list and the update-diff
path never read
visibility); only the Marketplace LISTING changes.
2. Centralized Choices (fl_choices)
Architecture
FlowLakes uses a centralized fl_choices table for ALL choice/select/status field values. Choice values are never defined inline in field definitions: they are defined in the choices array and referenced via choice_set.
Resolution Priority
The platform resolves choice values with a 3-tier fallback:
- Table-specific:
(tenant_id, "itsm_incidents", "priority"): highest priority - fl_tasks fallback:
(tenant_id, "fl_tasks", "priority"): shared defaults - Global:
(tenant_id, "*", "priority"): system-wide defaults
Choice Definition Format
{ "table_name": "itsm_incidents", "field_name": "status", "values": [ { "value": "new", "label": "New", "color": "#3B82F6", "sort_order": 1, "is_default": true, "allowed_transitions": ["assigned", "cancelled"] }, { "value": "assigned", "label": "Assigned", "color": "#8B5CF6", "sort_order": 2, "allowed_transitions": ["in_progress", "on_hold", "cancelled"] } ]}Choice Value Fields
| Field | Required | Type | Description |
|---|---|---|---|
value | Yes | string | Machine-readable value (snake_case) |
label | Yes | string | Human-readable display text |
sort_order | Yes | integer | Display order (1-based) |
is_default | No | boolean | Default selection (exactly one per choice set) |
color | Conditional | string | Hex color: only on lifecycle status fields (see Icon/Color Policy below) |
description | No | string | Tooltip or help text |
allowed_transitions | Conditional | string[] | Required for lifecycle status fields: see Section 3 |
Field Reference Format
Tables reference choice sets via choice_set (NOT inline options):
{ "field_name": "status", "field_type": "choice", "is_required": true, "default_value": "new", "is_indexed": true, "choice_set": "itsm_incidents.status"}The choice_set value is "table_name.field_name": it maps to the (table_name, field_name) in fl_choices.
Rules
- NEVER use inline
optionsarrays on choice fields: always usechoice_set - Every choice set MUST be defined in the
choicesarray BEFORE any table references it - Exactly one value per choice set should have
is_default: true - Non-lifecycle choice fields (priority, category, type, etc.) do NOT need
allowed_transitions
Icon & Color Policy
To avoid visual noise, icons and colors are strictly limited:
Icons: ONLY on:
- Navigation modules (
navigation.modules[].icon) - Navigation menu items (
navigation.modules[].items[].icon)
Colors: ONLY on:
- Lifecycle status choice values (powers progress stepper & status badges)
- Dashboard gauge thresholds (functional red/yellow/green for compliance)
Do NOT add icons or colors to:
metadata(noiconorcoloron the blueprint itself)- Table definitions (no
iconorcoloron tables) - Non-status choice values (priority, urgency, impact, type, category, environment, etc.)
- KPI card configs (platform handles default styling)
3. Lifecycle Status Fields & allowed_transitions
What is a Lifecycle Status?
A lifecycle status field tracks the progression of a record through defined states. Examples: incident status (new → assigned → in_progress → resolved → closed), change request status (draft → submitted → approved → in_progress → completed).
When allowed_transitions Are Required
allowed_transitions MUST be defined on choice values when:
- The field is named
statuson a task-enabled table (see Section 4) - The field is used as the
progress_fieldinfl_table_registry(see Section 4) - The field drives a state machine (approvals, escalations, SLA tracking)
Transition Rules
{ "value": "in_progress", "label": "In Progress", "color": "#F59E0B", "sort_order": 3, "allowed_transitions": ["on_hold", "resolved", "cancelled"]}allowed_transitionsis an array ofvaluestrings from the same choice set- An empty array
[]means the state is terminal (no further transitions allowed) - The platform UI uses transitions to render valid “next state” buttons
- The
EnhancedRecordViewrenders a progress stepper from theprogress_fieldchoices
Design Guidelines
| Pattern | Example | Transitions |
|---|---|---|
| Terminal state | ”closed”, “completed” | [] or single reopen option |
| Reopenable terminal | ”closed” → can reopen | ["new"] |
| Linear progression | draft → review → published | Each points to next only |
| Branching | in_progress → resolved OR cancelled | Multiple targets |
| Rollback | failed → can restart | ["draft"] or ["pending"] |
4. Table Registry (fl_table_registry)
Purpose
fl_table_registry configures task-enabled tables for:
- Auto-numbering: INC-000001, CHG-000042, etc.
- Progress bar: Visual lifecycle stepper in record view
- Default state/priority: Pre-populated on record creation
- Module grouping: Links table to a module for navigation
When to Register a Table
A table MUST be registered in fl_table_registry when it:
- Represents a process/transactional entity (incidents, requests, changes, tasks)
- Needs auto-numbering (human-readable identifiers)
- Has a lifecycle status that progresses through states
A table should NOT be registered when it:
- Is a configuration/reference table (categories, SLA definitions, priority matrices)
- Has no lifecycle (static lookup data)
- Is a relationship/junction table
Blueprint Table Definition Fields
Add these fields directly on the table definition object (alongside table_name, fields, etc.):
{ "table_name": "itsm_incidents", "display_name": "Incidents", "description": "IT service incidents requiring resolution", "task_enabled": true, "number_prefix": "INC", "show_progress_bar": true, "progress_field": "status", "default_state": "new", "default_priority": "medium", "module_slug": "itsm", "category": "itsm", "fields": [...]}Table Registry Fields Reference
| Field | Required | Default | Description |
|---|---|---|---|
task_enabled | Yes | N/A | Must be true to trigger registration |
number_prefix | Yes | N/A | Unique prefix for numbering (e.g., “INC”, “CHG”, “PRB”) |
show_progress_bar | No | false | Show lifecycle stepper in record view |
progress_field | No | null | Field name used for stepper (usually "status") |
default_state | No | "new" | Default status value for new records |
default_priority | No | "medium" | Default priority value for new records |
module_slug | No | null | Groups table under a module (e.g., “itsm”) |
number_format | No | "{prefix}-{number:0{padding}d}" | Number format template |
number_padding | No | 6 | Zero-padding width (6 → INC-000001) |
category | No | "general" | Table category for filtering |
access | No | absent (not restricted) | Record-sharing rule, written into fl_table_registry.access_rule. Exactly one of {"shared": true, "owner_field"?, "open_field"?, "open_value"?} or {"through": "<reference field on this table>"}. Declared only on a dynamic table THIS blueprint creates, never on a platform or static table. |
Child lists (child_lists)
A table may declare the lists of records that belong to one of its records. A child list is “rows of a child table whose link field points at this record”, in two link kinds:
- hierarchy:
"link": "parent_task_id". The child must be task-capable (it inheritsfl_tasks); the link lives on the child’sfl_tasksowner row. This is how a task record lists its sub-tasks. - reference:
"link"names a reference field on the child whosereference_tableis this table (incidents of a category, members of a group).
"child_lists": [ { "key": "sub_tasks", "label": "Sub-tasks", "child_table": "itsm_incident_tasks", "link": "parent_task_id", "columns": ["number", "short_description", "assignment_group", "assigned_to", "state"], "allow_add": true, "quick_add_fields": ["task_kind"] }]| Key | Required | Default | Description |
|---|---|---|---|
key | Yes | Stable identifier, unique per table. Becomes fl_related_config.relation_name. | |
child_table | Yes | The table whose rows are listed. Declared in this blueprint or a known platform table. | |
link | Yes | parent_task_id (hierarchy) or a reference field on the child targeting this table. | |
label | No | key, capitalised | The list’s heading on the record page. |
columns | No | [] (the surface’s default) | Fields of the child, or of fl_tasks for a task-capable child, shown as columns. |
allow_add | No | false | Show the inline add row. Adding needs create on the child table and edit on the parent record. |
quick_add_fields | No | [] | Fields the add row offers beyond the title and the team. |
The tables applier writes each list as one fl_related_config row
(relationship_type: "child", is_system: true), rewrites it on update,
and switches it off when the package stops shipping it. A hand-made related
list with the same key refuses the install by name. A table that declares
nothing keeps schema discovery as its fallback. Sub-tasks added through the
list are created by the task spawner, the same path a spawn_tasks rule and
catalog fulfilment use.
Default list columns (list_columns)
"list_columns": ["number", "sla_status", "short_description", "state", "assigned_to", "assignment_group"]
names the columns the table’s list shows when the reader has saved no column
preference; every other column stays in the column picker. Same key name as
child_lists[].columns. Each name is a field of the table, a field it
inherits, a system column, or sla_status (the SLA column) on a task-capable
table; the validator refuses any other name. The installer and every upgrade
store it on the table definition’s config; GET /tables/{t}/init returns
it as metadata.list_columns. The reader’s saved preference wins, and Reset
returns to this set. Absent: every column shows. null: clears it on upgrade.
Number leads by default. A table with a number column always shows it
first, whatever position the declaration gives it, or even with no
declaration at all (the list then falls back to the physical column order,
Number still leading). This is one rule read by both the backend (the
default the install and every upgrade store, normalized on the way in and
out) and the frontend grid (its own fallback for a table that declares no
default), so a declared set, a Studio-authored one and an undeclared table
all agree.
Numbering Uniqueness
Critical: Every number_prefix MUST be globally unique within a blueprint.
The platform auto-increments per (tenant_id, table_name) using an atomic counter.
| Table | Prefix | Example Number |
|---|---|---|
| itsm_incidents | INC | INC-000001 |
| itsm_problems | PRB | PRB-000001 |
| itsm_changes | CHG | CHG-000001 |
| itsm_change_tasks | CTSK | CTSK-000001 |
| itsm_knowledge_articles | KBA | KBA-000001 |
| itsm_configuration_items | CI | CI-000001 |
Session Mappings (session_mappings)
A record on a session-enabled table opens a session. session_mappings says
which SPECIALIST that session belongs to, and it is the only tier of selection
that earns a model run on its own. It replaces the trigger row a specialist
used to carry.
"session_mappings": [ { "table": "req_requests", "field": "request_type", "value": "access", "agent": "entra-access-agent" }]| Field | Required | Description |
|---|---|---|
table | Yes | A table this blueprint declares, or one it lists in system_tables[] |
field | Yes | A field on that table’s inheritance chain |
value | Yes | The value that selects the agent |
agent | Yes | The slug of an agent THIS blueprint declares |
Rules:
- The
(table, field, value)key must be unique in the file. Only the first rule on a key could ever be read. - The agent must be declared in the same blueprint’s
agents[]. A slug from another blueprint cannot be resolved, because install order is not a fact this file can assert.
Doors (doors)
A shipped external connection: the key FlowLakes ships ready-made, the
customer pastes a credential (service-operations direction D3). One entry is
the connection’s type and name, its provider, the config the RUNTIME
validator accepts for that type (operations with risk, verify,
subject, requires_permission), and the door note: the house rules every
session that reaches the connection is given.
"doors": [ { "capability_type": "rest_endpoint", "name": "microsoft-graph-write", "provider": "microsoft_graph", "config": { "base_url": "https://graph.microsoft.com/v1.0", "operations": [{ "name": "add_group_member", "method": "POST", "path": "/groups/{group_id}/members/$ref", "risk": { "grade": "low" } }] }, "note": "Rules for changing Entra through this connection ..." }]| Field | Required | Description |
|---|---|---|
capability_type | Yes | rest_endpoint, graph_query, mcp_server, email or web_fetch |
name | For multi-instance types | The instance slug (^[a-z0-9_-]{1,120}$); omitted for a singleton type’s default row |
provider | No | The provider label the row carries (microsoft_graph, generic_http) |
config | Yes | Validated by capability_service.validate_<type>_config, the runtime’s own grammar |
note | No | The door note body (kind door_note), bound to this connection |
Rules:
config.tablesis which tables’ sessions may reach the door: the word"all", or a list of table names. LEFT OUT, the door is closed to every session until an admin scopes it (closed by default, 2026-10-03). On a package update the customer’s stored scope wins; the package value is the install-time default only.enabled,posture,credential_idandcredentialare REFUSED by name. A blueprint ships no key material and opens nothing: the row installs DISABLED at postureobservewith no credential, and the customer binds a credential and enables it on the connection’s page.- On a tenant that already holds the connection, the config and provider are refreshed (the template) and the customer’s enabled state, posture and credential are kept. A note is refreshed, never duplicated; an entry without a note touches no note.
- An
approvals[]policy that conditions oncapability_name(a door override: who signs off this connection’s changes at a grade) must name a door THIS file declares. The override ships with its key. - The
(capability_type, name)key must be unique in the file.
Table Notes (table_notes)
A package’s declared addition to a RECORD TABLE’s standing note, for a table
this blueprint does not own (RMM E3-F). One entry names the tables an agent
session working a record of table may read beyond its own inheritance
chain, on top of whatever the human-authored note already declares.
"table_notes": [ { "table": "itsm_incidents", "consultable_tables": ["device_monitoring_findings", "fl_device_state"] }]| Field | Required | Description |
|---|---|---|
table | Yes | The record table the note governs; must already exist in the tenant’s table registry when this entry applies (a table this same package creates lands first) |
consultable_tables | Yes | Tables the session may consult beyond its own chain; each must be a tenant registry table or tenant-scoped system data |
Rules:
- UNIONED, additive: every table this entry declares is added to the note’s stored set, whether or not the tenant already has it, and nothing a tenant added on their own is ever removed because this package stopped mentioning it.
- A table this entry stops declaring is removed from the tenant’s note only when the UPGRADE BASELINE (what this package last shipped) carried it. An unknown baseline can only ever add, never remove.
- Not an approval, a role or a connection:
table_notescarries no key of its own and nothing to retire. Uninstalling the package leaves the note exactly as it is. - The
tablekey must be unique in the file; a second entry for the same table is a second producer of one declaration. - The mapping is applied through
selection.save_session_config, which is the ONE writer offl_table_registry.sessions_enabledand.session_config. Applying setssessions_enabledto true on the table. - Applying is a MERGE and is idempotent. A rule the file declares is added once; rules the file does not declare are left alone, because a tenant may have written its own on the table’s Session section. A key the tenant already points at a different agent is kept as the tenant has it, and the install reports that in its warnings.
- A tenant that does not have the table simply has nothing to map. That is a named skip in the install warnings, not a failure.
- An agent that carries a
session_mappingsrule must carry NO trigger row on that table. A trigger row makes the agent the table’s owner, and the session never runs at all.
Agents (agents): the model
An agent entry may name its model with model_id. In a package that is the
PROVIDER model id (for example claude-sonnet-5), never a row id: the row
fl_agents.model_id points at is per tenant, and a package installs into any
tenant. The installer and the upgrader resolve the reference (row id, name or
provider model id, among the active models the tenant may see) and store the
tenant’s own row id.
Rules:
- A reference that resolves to nothing REFUSES that agent by name in the install result. It never falls back to the tenant default: a run on a model nobody chose is unattributable (AGENTS_AND_SKILLS, “No provider fallback”).
- No
model_idmeans the tenant default, which is a chosen default. - The reference must be a model the platform provides to every tenant, or installs fail on tenants that lack it.
5. Creation Order (Phase-by-Phase)
Build blueprints in this exact order. Each phase depends on the previous.
Phase 1: DATA ARCHITECTURE (choices → tables → relationships)
Choices first: every table with choice/status fields depends on choices being defined.
Checklist:
- Define ALL
choicesentries before any table references them viachoice_set - Identify entity types: configuration/reference vs. process/transactional
- Every process table gets
task_enabled: true+number_prefix - Every entity with lifecycle gets a
statusfield (type:choice) withallowed_transitions - Tables with
task_enabled: truegetshow_progress_bar,progress_field,default_state,default_priority,icon,color,module_slug - User references:
field_type: "reference",reference_table: "users"(NEVER string) - Group references:
field_type: "reference",reference_table: "groups" - Self-references allowed (e.g.,
parent→ same table for hierarchies) - Every choice value has:
value,label,color,sort_order; one hasis_default: true - Computed/readonly fields include
ui_config: {"readonly": true} - Group fields into
ui_config.sectionfor form layout (Details, Classification, Assignment, Schedule, Resolution, etc.). Sections render in the order their first field appears;Resolutionis the record view’s resolution block; a hidden field needs none. A table of six fields or fewer is oneDetailssection. - A table with
parent_table: "fl_tasks"declares EVERY inheritable parent field it shows: with a section, or with"visible": falsewhen the application does not use it (an Agile work item has no impact, urgency, catalog item or SLA clock). An undeclared inherited field renders in General. The service desk set: Details, Assignment, Classification, SLA, Related (catalog item, service request, links to other records), Resolution, Closure. No section holds more than eight fields. - Add
is_indexed: trueon: status, priority, assigned_to, category, and frequently filtered fields
Phase 2: BUSINESS LOGIC (rules → doors → approvals → workflows)
Checklist:
- Doors before approvals: a door override names its connection, so the
doors[]entry comes first - Rules: valid
rule_type(calculated|validation|action|workflow_trigger), validtrigger_type(before_insert|before_update|after_insert|after_update|on_demand|scheduled) - NEVER use:
before_save,set_value,rollup(not executable) - Status-driven tables need: timestamp rules (resolved_at, closed_at), validation gates (require resolution_notes before resolved)
- Set
execution_orderexplicitly (10=calculated, 50=validation, 100=action) - Workflows: valid triggers (
record_insert|record_update|field_change|scheduled|manual), valid actions (update_record|create_record|send_notification|send_email|conditional|execute_script|query_records|approval_request) - Approvals: use platform-native
FlApprovalPolicy: NEVER create custom approval tables - Multi-stage approvals:
approval_type: "sequential"with numberedstages
Phase 3: PRESENTATION (dashboards → notifications → navigation → roles)
Checklist:
- Each major module gets 1+ dashboards. Start with 3 KPIs at
y=0(w=3,3,4,h=2) - Dashboard
permissions: optional array of{role_code, permission_type}(addrole_nametoo: migrator-run installs resolve roles by NAME; roles have no code column) to restrict visibility to specific roles. Omit for public dashboards. Permission types:view,edit,manage - Grid: 10 columns wide: every row MUST sum to exactly 10 (the desktop
grid is 10 columns at every width; see
DashboardGrid.jsx::GRID_COLS. The old 12-column guidance produced rescaled fractional widths and right-edge gaps on every screen). Account for tall (h=4) widgets spanning into the next row band when summing. Proven row recipes:3+3+4(KPIs),4+6(chart pair),3+7(kpi+wide chart),6+4(queue+chart). Owner-ratified reference layout:entra_access_management.json→ “Identity Governance Overview”. - KPI heights
h=2; charts/tables/queuesh=4. No row may leave a trailing gap: a dashboard must fill the screen edge to edge. - Notifications cover ALL critical lifecycle events (created, assigned, resolved, escalated)
- Templates use
{{field_name}}mustache syntax with bothsubject_templateandbody_template - Navigation: each functional area = one module. First item = dashboard (landing page)
- Filtered views: “My X” (
assigned_to: "$current_user"), “Open X” (status: {"$nin": [...]}) - Minimum 3 roles: Admin (full), Manager (department-wide), User (own records)
- Permission format:
table_name:actionortable_name:*
Phase 4: SEED DATA
- ALL configuration/reference tables get seed data
- Seed data values MUST match defined choice values exactly
- Do NOT seed process tables (incidents, tickets): created by users
6. Valid Enumerations
Field Types
string | text | integer | decimal | boolean | date | datetime | email | phone | url |currency | percent | json | reference | select | multiselect | file | image |rich_text | status | tags | rating | color | uuid | choiceRule Types
| rule_type | trigger_type | Config Pattern |
|---|---|---|
calculated | before_insert, before_update | script_body or formula + applies_to_field |
validation | before_insert, before_update | config: {validation, field, error_message} |
action | before_insert, before_update, after_insert, after_update | action_config: {set_fields: {}}: or a named action: spawn_tasks, run_judgment, intercept_duplicate, cascade_duplicates |
workflow_trigger | after_insert, after_update | triggers a workflow |
Workflow Action Types
execute_script | update_record | create_record | delete_record | query_records |send_email | send_notification | call_api | rest_api_call | call_workflow |approval_request | conditional | loop | wait | ai_process | execute_ai_skill |parallel | set_variableApproval Types
single | any | all | majority | percentage | sequential
Dashboard Visualization Types
table | bar | line | pie | donut | area | scatter | kpi | gauge | heatmap | radar | funnel | treemap
Notification Trigger Events
create | update | delete | state_change | field_change | comment_added | attachment_added
Notification Recipient Types
user | role | group | field_value | dynamic
7. Common Patterns (Copy-Paste Templates)
Choice Set with Lifecycle Transitions
{ "table_name": "my_table", "field_name": "status", "values": [ { "value": "new", "label": "New", "color": "#3B82F6", "sort_order": 1, "is_default": true, "allowed_transitions": ["in_progress"] }, { "value": "in_progress", "label": "In Progress", "color": "#F59E0B", "sort_order": 2, "allowed_transitions": ["resolved", "cancelled"] }, { "value": "resolved", "label": "Resolved", "color": "#10B981", "sort_order": 3, "allowed_transitions": ["closed", "in_progress"] }, { "value": "closed", "label": "Closed", "color": "#1F2937", "sort_order": 4, "allowed_transitions": [] }, { "value": "cancelled", "label": "Cancelled", "color": "#EF4444", "sort_order": 5, "allowed_transitions": [] } ]}Task-Enabled Table Definition
{ "table_name": "my_tickets", "display_name": "Tickets", "description": "Support tickets for tracking customer issues", "task_enabled": true, "number_prefix": "TKT", "show_progress_bar": true, "progress_field": "status", "default_state": "new", "default_priority": "medium", "module_slug": "support", "category": "support", "fields": [ { "field_name": "short_description", "field_type": "string", "field_length": 200, "is_required": true, "ui_config": { "section": "Details", "width": "full" } }, { "field_name": "status", "field_type": "choice", "is_required": true, "default_value": "new", "is_indexed": true, "choice_set": "my_tickets.status", "ui_config": { "section": "Details" } }, { "field_name": "priority", "field_type": "choice", "is_required": true, "default_value": "medium", "is_indexed": true, "choice_set": "my_tickets.priority", "ui_config": { "section": "Details" } }, { "field_name": "assigned_to", "field_type": "reference", "reference_table": "users", "is_indexed": true, "ui_config": { "section": "Assignment" } }, { "field_name": "assignment_group", "field_type": "reference", "reference_table": "groups", "ui_config": { "section": "Assignment" } } ]}Status Timestamp Rule (action)
{ "name": "Set Resolved Timestamp", "table_name": "my_table", "rule_type": "action", "trigger_type": "before_update", "applies_to_field": "resolved_at", "execution_order": 100, "is_active": true, "condition": { "field_changes": ["status"], "to_value": "resolved" }, "action_config": { "set_fields": { "resolved_at": "NOW()", "resolved_by": "$current_user" } }}Duplicate Interception at Intake (action)
Links a new record to an OPEN record on the same table that reports the same
issue. An exact subject match links deterministically with no AI call; the
optional fuzzy block nominates similar-but-reworded open siblings for free
and confirms them with ONE governed skill call off the request path. A linked
duplicate STAYS OPEN and inherits the master’s resolution at cascade time (see
the companion action below).
{ "name": "Intercept Duplicate at Intake", "table_name": "itsm_incidents", "rule_type": "action", "trigger_type": "after_insert", "execution_order": 5, "is_active": true, "action_config": { "intercept_duplicate": { "match_field": "short_description", "link_field": "duplicate_of", "window_days": 7, "fuzzy": { "candidate_threshold": 0.3, "confirm_threshold": 0.75, "top_n": 3 }, "escalate": { "threshold": 3, "field": "impact", "ladder": ["low", "medium", "high"], "matrix_table": "itsm_priority_matrix", "priority_field": "priority" } } }}link_field must be a reference field on the table pointing at itself
(declare it in fields and in relationships), never parent_task_id: that
is the sub-task relation. Exact matching is EXACT after normalization; a scan
that hits its row cap reports unknown and lets the record through normal
intake rather than declaring it new. fuzzy requires the duplicate-confirm
skill (system or tenant scope): without it the confirm task fails loudly.
escalate raises the master’s field one rung on ladder when the count of
OPEN linked duplicates reaches threshold, re-resolving priority_field from
matrix_table. Order the rule FIRST among after_insert rules: anything
keying off the link (agent triggers, later assessment) must run after the
interception has happened.
Cascade Resolution to Duplicates (action)
The other half of the parent-child model: when the master reaches a terminal
state, every OPEN record linked to it is resolved with the master’s state and
resolution notes, resolved_at stamped, and each duplicate’s caller notified.
{ "name": "Cascade Resolution to Duplicates", "table_name": "itsm_incidents", "rule_type": "action", "trigger_type": "after_update", "execution_order": 20, "is_active": true, "condition": { "field_changes": ["state"] }, "action_config": { "cascade_duplicates": { "link_field": "duplicate_of", "caller_field": "caller" } }}Terminal-ness comes from sla_runtime.resolve_sla_state_sets, never a state
literal in the rule: a tenant that renames its terminal states keeps a
working cascade. A partial cascade (some duplicates failed to resolve) reports
success: false with the skipped ids.
Required Field Validation
{ "name": "Require Notes for Resolution", "table_name": "my_table", "rule_type": "validation", "trigger_type": "before_update", "execution_order": 50, "is_active": true, "condition": { "field_changes": ["status"], "to_value": "resolved" }, "config": { "validation": "not_empty", "field": "resolution_notes", "error_message": "Resolution notes are required." }}Workflow Action: send_notification
Use send_notification inside a workflow’s actions[] when the workflow
needs to dispatch a notification as part of its execution. For standalone
notification rules (not tied to a workflow), use the top-level
notifications[] section instead.
{ "action_type": "send_notification", "name": "Notify Assignment Group", "order_index": 1, "config": { "to": "record.assignment_group", "subject": "[{{priority}}] New Incident {{number}}: {{short_description}}", "body": "Body text with {{record.field}} templates.", "channels": ["in_app", "email"] }}Recipient shorthand for config.to: resolved by the compiler’s
resolve_recipient logic at install time. Pick one:
| Shorthand | Meaning | Resolves to |
|---|---|---|
"role:ROLE_NAME" | All users with this role | recipient_type: "role", recipient_config: {"role_names": ["ROLE_NAME"]} |
"group:GROUP_NAME" | All users in this group | recipient_type: "group", recipient_config: {"group_ids": ["GROUP_NAME"]} |
"record.FIELD_NAME" | User in a field on the triggering record | recipient_type: "field_value", recipient_config: {"field": "FIELD_NAME"} |
"{{record.FIELD_NAME}}" | Templated field reference (equivalent) | same as above |
"user@example.com" | Specific email address | recipient_type: "email", recipient_config: {"emails": [...]} |
"<uuid>" | Specific user by ID | recipient_type: "user", recipient_config: {"user_ids": [...]} |
["a", "b"] | Merge list of recipients of same type | compiler merges into a single config |
Precompiled form (advanced, skip shorthand): pass
recipient_type + recipient_config explicitly; useful when the shorthand
cannot express the intent (e.g., dynamic expressions):
{ "config": { "recipient_type": "dynamic", "recipient_config": {"expression": "record.manager_email"}, "subject": "...", "body": "...", "channels": ["email"] }}Additional optional keys inside config:
cc: same shorthand grammar astotemplate_id: reference aFlNotificationTemplateby ID (replaces inline subject/body)- Templates anywhere (subject, body) use
{{record.field}}/{{variables.name}}mustache syntax
Workflow Action: conditional
Gate subsequent actions on an expression. The ONE valid shape:
{ "action_type": "conditional", "name": "Check Priority Level", "order_index": 0, "config": { "conditions": [ {"left": "priority", "operator": "equals", "right": "critical"} ], "conditionLogic": "AND" }}Required: config.conditions (a non-empty array of {left, operator, right}, operator one the engine runs: equals, not_equals, contains,
starts_with, ends_with, greater_than, less_than, is_empty, is_not_empty,
is_null, is_not_null) plus config.conditionLogic (“AND” or “OR”).
Branch routing is NOT a key on the conditional action itself. Which
actions run on the true branch and which on the false branch is expressed
on the parent WORKFLOW’s own config.edges[], each edge carrying a
sourceHandle naming the branch it leaves (true/false) and a target
naming the next action’s node. Author a branching workflow through the SDK
(wf.when(wf.condition("field", "op", value)), with the gated actions
inside the with-block) and compile it, rather than hand-building the
graph; the compiler emits conditions, conditionLogic and the edges
together so they never disagree.
config.condition (a free-text expression string) plus
config.true_actions/config.false_actions (naming sibling actions by
name) is a DEAD dialect from an earlier design: the workflow engine never
reads those three keys, so a conditional authored
that way installs clean and silently runs every action unconditionally.
The validator refuses it by name.
Workflow: on a state change, create a record and move the state
A workflow is triggers[] + actions[]. Not trigger + steps: that is the
chat spelling, and a draft refuses it. Values from the record that fired the
trigger are {{trigger.record.<field>}}; recordSource: "trigger" updates
that same record.
{ "name": "Order on approval", "status": "active", "triggers": [ { "trigger_type": "record_update", "table_name": "my_requests", "conditions": { "field_changes": ["state"], "to_value": "approved" } } ], "actions": [ { "action_type": "create_record", "name": "Create order", "order_index": 1, "target_table": "my_orders", "config": { "field_mappings": { "request": "{{trigger.record.id}}", "amount": "{{trigger.record.total_amount}}" } } }, { "action_type": "update_record", "name": "Mark ordered", "order_index": 2, "target_table": "my_requests", "config": { "recordSource": "trigger", "field_mappings": { "state": "ordered" } } } ]}Multi-Stage Approval
{ "name": "Standard Approval", "target_tables": ["my_table"], "approval_type": "sequential", "approver_type": "role", "auto_trigger_conditions": [{ "field": "state", "operator": "equals", "value": "pending_approval" }], "stages": [ { "stage_number": 1, "name": "Manager", "approver_type": "role", "approver_config": { "role_name": "manager" }, "approval_type": "single", "timeout_hours": 48 }, { "stage_number": 2, "name": "Director", "approver_type": "role", "approver_config": { "role_name": "director" }, "approval_type": "single", "timeout_hours": 48 } ], "on_approved_state": "approved", "on_rejected_state": "rejected", "timeout_hours": 48, "protected_fields": ["critical_field"], "is_active": true}Service Hub Item
Every key the service hub items installer reads, with a realistic example value. This section is generated from the installer’s own declared keys and cannot drift from what install actually reads (see the known-key declaration in the blueprints area).
This item declares no field or question of its own. The questions a requester answers are the target form’s fields, authored under the top level forms section and bound to this item by name (form_name); install raises if that form does not exist yet, so forms must be authored before service hub items in the phase order. field_defaults on this item only pre-fills values onto that form’s own fields for submissions through this one tile; it is read, but it never declares a field.
{ "name": "Request New Laptop", "description": "Submit a request for a laptop for a new hire or to replace a broken device. IT fulfills approved requests and ships the device to the requester's desk.", "short_description": "Order a laptop for a new hire or a replacement", "category_name": "Hardware", "category_description": "Hardware requests and replacements", "category_icon": "laptop", "form_name": "Laptop Request Form", "target_table": "req_requests", "icon": "laptop", "order": 10, "keywords": [ "laptop", "hardware", "equipment" ], "is_active": true, "is_featured": true, "requires_approval": true, "approval_config": {}, "sla_hours": 48, "visibility": "all", "visible_to": [], "field_defaults": { "priority": "medium" }, "fulfillment_tasks": [ { "key": "provisioning", "child_table": "req_request_tasks", "short_description": "Provision laptop", "task_type": "provisioning", "state": "pending", "sequence": 1, "assignment_group": "IT Hardware" } ], "fulfillment_spawn_on": "on_approval", "approval_policy": "Hardware Approval", "approval_policy_id": null, "names": { "de": "Laptop anfordern" }, "short_descriptions": { "de": "Laptop für einen neuen Mitarbeiter oder als Ersatz bestellen" }, "descriptions": { "de": "Fordern Sie einen Laptop für einen neuen Mitarbeiter oder als Ersatz an. Das IT-Team bearbeitet genehmigte Anfragen und versendet das Gerät an den Arbeitsplatz der anfragenden Person." }, "category_names": { "de": "Hardware" }, "category_descriptions": { "de": "Hardware-Anfragen und Ersatzteile" }}| Key | Meaning |
|---|---|
name | The item’s natural key within the tenant; also the default portal tile title unless the names map overrides it per language. Reinstalling with the same name updates the existing tile rather than creating a second one. |
description | The long description shown on the item’s own detail page. |
short_description | The one line summary shown on the catalog tile itself. |
category_name | The name of the top level category this item lists under. Auto-creates the category for this tenant on first use if none with this name exists yet; an existing category is reused, never re-created or re-touched by a later item that joins it. |
category_description | The category’s own description, stamped only the first time an item’s install creates that category for the tenant. |
category_icon | The category’s own icon, stamped only the first time an item’s install creates that category for the tenant. |
form_name | The name of an existing form (the forms family) this item launches when a requester opens the tile. Install raises if no form with this name exists yet for the tenant, so forms must be authored before service hub items in the phase order. |
target_table | The table a submission through this item writes its record into. |
icon | The icon shown on the item’s own portal tile (separate from the category’s icon). |
order | The tile’s sort position within its category, ascending. |
keywords | Extra search terms the portal’s catalog search matches against, besides the name and description. |
is_active | Whether the tile is enabled. False hides it from the portal immediately; a later reinstall that omits this key defaults back to true, switching a previously retired tile back on. |
is_featured | Whether the tile is promoted to the portal’s featured section. |
requires_approval | Whether a submission needs an approval decision before fulfillment proceeds. This flag alone does not route the approval; bind approval_policy (or, as a deprecated fallback, approval_config) so there is somewhere for it to route to. |
approval_config | Deprecated inline approval settings (approvers, approval type, timeout hours, on timeout action), used only as a fallback when the item has no bound approval_policy. New items should set approval_policy instead and leave this empty. |
sla_hours | The SLA target, in hours, for submissions through this item, before any priority based override. |
visibility | Who can see the tile: “all” for every authenticated user, or “group” / “role” to narrow it further with visible_to. |
visible_to | The group or role ids the tile is restricted to when visibility is “group” or “role”; ignored when visibility is “all”. |
field_defaults | Values this item pre-fills onto its target form’s own fields for every submission through this tile. The item declares no field of its own here; the form named by form_name is what defines the questions asked (see the note above this table). |
fulfillment_tasks | The sub-task specs spawned once a submission reaches this item’s spawn point, passed as-is to the shared task spawner. |
fulfillment_spawn_on | When fulfillment_tasks spawn: “on_create” (immediately) or “on_approval” (the default; when the request reaches in_progress). |
approval_policy | The name of an approval policy (the approvals family) this item’s requests route through; resolved to approval_policy_id at install time. |
approval_policy_id | The resolved id of the bound approval policy. Normally left out and resolved automatically from approval_policy by name. |
names | Per-language overrides of name; a language missing from this map falls back to name itself. |
short_descriptions | Per-language overrides of short_description. |
descriptions | Per-language overrides of description. |
category_names | Per-language overrides of category_name, read only the first time this item’s install creates the category. |
category_descriptions | Per-language overrides of category_description, read only the first time this item’s install creates the category. |
Every other draft family’s template (M32)
Every family a draft covers teaches its shape here the same way: generated straight from the keys that family’s installer reads, never hand typed, and validated clean by the blueprint validator.
Role
{ "name": "Loan Officer", "purpose": "Reviews and approves loan requests.", "record_grants": [ { "table_name": "loan_requests", "verbs": [ "see", "change" ] } ], "platform_capabilities": [ "platform.rules.read" ]}| Key | Meaning |
|---|---|
name | The role’s natural key within the tenant. A draft only CREATES roles: a name that already exists in this workspace is refused, never silently reused or overwritten; an approval step or a workflow action may still name an existing role without this family declaring it. admin and super_admin are reserved and refused for any role, new or existing. |
purpose | A short sentence on who holds this role and why, 160 characters or fewer. |
record_grants | What this role may do on a table: table_name (installed, or declared in this same draft’s own tables or roles phase) and verbs, each one of see, create, change, delete, export, set_up, administer. A verb outside that list is refused by name. |
platform_capabilities | Platform capability names this role carries (the same vocabulary get_platform_capabilities lists). A Builder may grant a read/access/list capability freely, but may grant any other capability only when the Builder’s own account already holds it, and may never grant platform.* admin; the refusal names the missing capability. |
Group
{ "name": "IT Support", "description": "Tier 1 IT support, first responders on incidents.", "members": [ "user-1234", "ava@example.com" ], "leader": "ava@example.com"}| Key | Meaning |
|---|---|
name | The group’s natural key within the tenant. Reinstalling with the same name adopts the existing group (adds the members it lacks) rather than creating a second one. |
description | A short description of who this group is for and what it routes. |
members | This group’s members, each a tenant user’s id or email. Every entry must be an active user of this tenant; install never removes a member an earlier install added. |
leader | This group’s leader, by id or email. Must be one of members. An existing group that already has a different leader keeps it; the install result and readback name the difference. |
Choice Set
{ "table_name": "loan_requests", "field_name": "state", "values": [ { "value": "draft", "label": "Draft", "sort_order": 1, "color": "gray", "allowed_transitions": [ "submitted" ] }, { "value": "submitted", "label": "Submitted", "sort_order": 2, "color": "blue", "allowed_transitions": [ "approved", "rejected" ] }, { "value": "approved", "label": "Approved", "sort_order": 3, "color": "green", "allowed_transitions": [] }, { "value": "rejected", "label": "Rejected", "sort_order": 4, "color": "red", "allowed_transitions": [] } ]}| Key | Meaning |
|---|---|
table_name | The table this choice set belongs to, together with field_name. |
field_name | The field this choice set fills. For a table’s lifecycle, this is state. |
values | The choice set’s own values, in order. Each entry needs value and label; a lifecycle field’s entries (field_name: state) also carry color and allowed_transitions (the states this one may move to), and may declare locks_record (true: a record in this state refuses every further write), requires (fields a write entering this state must carry, each {field, min_length?}), and counts_as_reopen (true: reaching this state from a closed one is a reopen). |
Table
{ "table_name": "loan_requests", "display_name": "Loan Requests", "description": "Employee requests for a company loan.", "icon": "banknote", "fields": [ { "field_name": "number", "field_type": "string", "field_length": 50, "is_indexed": true, "ui_config": { "readonly": true } }, { "field_name": "amount", "field_type": "number", "is_required": true, "is_indexed": true }, { "field_name": "needs_review", "field_type": "boolean", "default_value": false } ], "task_enabled": true, "parent_table": "fl_tasks", "display_names": { "de": "display name (DE)" }, "descriptions": { "de": "description (DE)" }, "access": null, "routing": null, "child_lists": [], "list_columns": [ "number", "amount", "state" ], "aliases": { "title": "short_description", "summary": "short_description" }, "category": "loans", "color": "blue", "default_priority": "medium", "default_state": "draft", "module_slug": "loans", "number_format": "LN-{number}", "number_padding": 5, "number_prefix": "LN", "progress_field": "state", "show_progress_bar": true, "table_type": "physical"}| Key | Meaning |
|---|---|
table_name | The table’s natural key, and its physical table name. Reinstalling with the same name converges onto the existing table’s fields rather than creating a second table. |
display_name | The table’s display name, shown in navigation and forms. |
description | A short description of what this table stores. |
icon | The table’s icon name, from the platform’s icon set. |
fields | The table’s own fields, in order. A field an ancestor already owns (via parent_table) is not redeclared here. A task_enabled table with a number_prefix must declare its own number field (the platform writes the generated number into it at insert time, but creates no column for it on its own). See the Table Field template below for one entry’s own keys. |
task_enabled | Whether this table inherits fl_tasks’ shared fields (a state lifecycle, record number, activity timeline, SLA). A process table (something people act on) sets this true; a reference or configuration table leaves it false. |
parent_table | An ancestor table this one inherits fields from. A task_enabled table names fl_tasks explicitly here so a condition or rule that reads an inherited field (state) resolves it at design time, not only at runtime; an ancestor beyond fl_tasks is also set here. |
display_names | Per-language override of display_name for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
descriptions | Per-language override of description for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
access | An explicit row-access rule for this table, beyond ordinary role permissions. |
routing | Service-request routing config for this table, when it is a service hub target. |
child_lists | Related-record lists to show on this table’s own record page, pulled from a child table. |
list_columns | The default columns a list view of this table shows. |
aliases | Alternate names an MCP write/read may use for one of this table’s real fields (R4 Z10a-2): {alias: real_field_name}. The real field must be a declared or inherited field of this table, and an alias name may not itself be a real or inherited field name (blueprint_validator refuses either at compile time). field_aliases::field_aliases_of is the one reader (the MCP door, before the value gate, and get_table_details). |
category | The task registry category this table’s records file under, for a task_enabled table. |
color | The task registry’s accent color token for this table, never a hex value. |
default_priority | A new record’s starting priority, for a task_enabled table that uses priority. |
default_state | A new record’s starting state, which must be one of this table’s own declared state choice values. |
module_slug | The navigation module this table’s records are filed under, by slug. |
number_format | The template for this table’s generated record number. |
number_padding | How many digits the generated number pads to. |
number_prefix | This table’s record-number prefix. Must be unique across every task_enabled table in the tenant. |
progress_field | The field holding this table’s lifecycle progress. Always state on a task_enabled table; the validator refuses any other value. |
show_progress_bar | Whether the record page shows a progress bar driven by progress_field. |
table_type | physical (a real dynamic table) or virtual (a view over other tables). Defaults to physical. |
Table: fields[] (Table Field)
| Key | Meaning |
|---|---|
field_name | The field’s column name. name is the blueprint alias read when field_name is absent. |
name | Alias for field_name, read when field_name is absent. |
field_type | The column’s data type. type is the blueprint alias read when field_type is absent. |
type | Alias for field_type, read when field_type is absent. |
field_length | The column’s length, for a string field. On a text field it is the most characters a write may carry; a longer value is refused. |
is_required | Whether the column disallows null. |
is_unique | Whether the column is unique, scoped per tenant (never a bare unique across every tenant’s rows). |
is_indexed | Whether the column is indexed. is_searchable is the blueprint alias read when is_indexed is absent. |
is_searchable | Alias for is_indexed, read when is_indexed is absent. |
default_value | The column’s default value for a new row. |
reference_table | The table a reference-type field points at. |
reference_field | The column on reference_table this field resolves to. Defaults to id. |
validation_rules | Extra validation rules for this field’s value. |
ui_config | Form-rendering config for this field (widget, columns, …). |
choice_set | The choice set (by table_name/field_name in the choices family) this field’s values come from, for a choice-type field. Stored onto ui_config.choice_set. |
write_mode | Who may write the field: always, create_only (only when the record is created), never, or system (the platform only; a door write is refused whatever the value). Omitted, it derives from readonly/create_only. |
Business Rule
{ "table_name": "loan_requests", "name": "Flag large loans", "description": "Flags a loan request over the auto-approval threshold.", "is_active": true, "events": "updated", "match": "all", "conditions": [ { "field": "amount", "check": "greater_than", "value": 5000 } ], "logic": "steps", "steps": [ { "type": "set_field", "field": "needs_review", "value": true } ], "rule_type": null, "script_body": null}| Key | Meaning |
|---|---|
table_name | The table this rule runs on: installed, or declared in this draft’s own tables phase. |
name | The rule’s natural key within the tenant together with table_name. Reinstalling with the same pair updates the existing rule rather than creating a second one. |
description | A short description of what this rule does. |
is_active | Whether the rule is enabled. Defaults to true. |
events | When this rule runs: created, updated, or created_or_updated. |
match | Whether all or any of the conditions must hold. |
conditions | Conditions that must hold for this rule to run. Every field must be a real field on table_name; every check must be one the engine runs. |
logic | steps (the common case: an ordered list of actions), or script (a brand-new rule with a scripted body; an existing script or formula rule’s logic cannot be changed here, only its name, description and on/off switch). |
steps | An ordered list of actions this rule performs when logic is steps: set_field, assign, stop_save, request_approval, create_tasks, start_workflow. |
rule_type | For a brand-new script rule only: calculated, validation, or action. |
script_body | For a brand-new script rule only: the script body. See The scripting sandbox. |
Approval Policy
{ "name": "Flag large loans", "table": "loan_requests", "description": "Approval for loan requests over the auto-approval threshold.", "is_active": true, "moment": { "kind": "created" }, "if_": { "conditions": [ { "field": "amount", "check": "greater_than", "value": 5000 } ] }, "steps": [ { "who": [ { "kind": "role", "role_name": "finance_manager" } ], "decide": "any", "hours": 24, "on_timeout": "reject" } ], "then": { "approved_state": "approved", "rejected_state": "rejected" }, "advanced": {}}| Key | Meaning |
|---|---|
name | The policy’s natural key within the tenant. Reinstalling with the same name updates the existing policy rather than creating a second one. |
table | The ONE record this policy asks about: installed, or declared in this draft’s own tables phase. |
description | A short description of what this policy approves. |
is_active | Whether the policy is enabled. Defaults to true. |
moment | When this policy asks: kind is created, updated (plus fields, the names whose change asks; empty means any update asks), created_or_updated, or explicit (only a rule or a workflow triggers it by name; fields is ignored for every kind but updated/created_or_updated). |
if_ | The If part (send it as if_ or if): conditions that must all hold for this policy to ask. Every field must be a real field on table; every check must be one the approval engine runs. |
steps | An ordered list of who decides, one stage per entry (more than one entry runs as sequential stages, whatever their own shape). Each step: who (a list naming people: kind is person (plus person_id), role (plus role_name, a role of this tenant, or one this same draft’s roles phase declares), team (plus team_id), requester_manager (plus fallback_team, required: the team that decides when the requester has no manager), manager_of_field or person_in_field (plus field, a record field holding a person)); decide, any one of them (the default), or all of them, offered only on a single-step policy; hours, how long before it times out (1 to the platform’s own maximum); on_timeout, always reject (nothing else is offered, so the field is kept only to refuse any other value by name, never silently accept one). |
then | What happens on a decision: approved_state and rejected_state (both optional; either must be one of table’s own state choices, when it has a state field and declared choices), approved_set_fields (field/value pairs set only on approval; the value is never an object), locked_fields (fields nobody may edit while the policy waits), lock_all_edits (locks every field instead of naming them), and requester_may_cancel (defaults to true). |
advanced | code, a stable short code for this policy (optional); position, this policy’s 1-based place in the order policies on table are tried (optional; a new policy with no position goes last). |
Workflow
{ "table_name": "purchase_orders", "name": "Notify manager on large purchase", "description": "Notifies the requester's manager when a purchase order is over the approval threshold.", "is_active": true, "when": { "event": "updated", "field": "status", "changes_to": "approved" }, "match": "all", "conditions": [ { "field": "amount", "check": "greater_than", "value": 5000 } ], "steps": [ { "type": "notify", "to": "requester", "channel": "in_app", "message": "Your purchase order was approved." } ]}| Key | Meaning |
|---|---|
table_name | The table this workflow runs on: installed, or declared in this draft’s own tables phase. |
name | The workflow’s natural key within the tenant together with table_name. Reinstalling with the same pair updates the existing workflow rather than creating a second one. |
description | A short description of what this workflow does. |
is_active | Whether the workflow is on. On an update, leaving it out keeps the stored status. A new workflow must set it. |
when | The moment this workflow starts: event is created, updated, field_changed, scheduled, or manual. field_changed also takes field and, optionally, changes_to; scheduled takes either every_day_at (HH:MM in the tenant’s timezone) or cron. |
match | Whether all or any of the conditions must hold. |
conditions | Conditions that must hold for this workflow to start. Every field must be a real field on table_name. |
steps | An ordered list of steps this workflow runs: request_approval, create_task, notify, update_record, or wait. A script step may only point at an existing script row or one just drafted through the script assistant; it cannot be typed from blank here. |
Notification Rule
{ "name": "Notify finance on large loans", "table": "loan_requests", "on": true, "moment": { "kind": "created" }, "if_": [ { "field": "amount", "operator": "greater_than", "value": 5000 } ], "who": { "kind": "role", "values": [ "finance_manager" ] }, "how": [ "in_app" ], "what": { "subject": "Large loan request", "message": "{{requester_name}} requested {{amount}}." }, "advanced": {}}| Key | Meaning |
|---|---|
name | The rule’s natural key within the tenant together with table. Reinstalling with the same pair updates the existing rule rather than creating a second one. |
table | The table this rule watches: installed, or declared in this draft’s own tables phase. |
on | Whether the rule is enabled. Defaults to true. |
moment | When this rule fires: kind is created, updated, field_changes (plus fields, the names whose change fires it), state_changes, comment_added, file_attached, assigned, sla_breached, or deleted. Never unsupported: that value exists only to read back a shape this spec cannot express, and a draft sending it is refused. |
if_ | The If part (send it as if_ or if): a list of conditions, every one of which must hold. Every field must be a real field on table; every operator must be one the notification engine runs. |
who | ONE audience (to tell a second audience, draft a sibling rule): kind is record_person or record_team (plus values, exactly one field on table holding the person or team; record_person may also carry include_cc, more such fields to copy), role (plus values, role names of this tenant), team (plus values, group ids), person (plus values, user ids), or email (plus values, email addresses). leave_out_actor (default true) drops whoever caused the event from the audience. |
how | The channels this rule sends on: in_app, email, or both. Defaults to [“in_app”]. |
what | What is said: subject and message (plain text; a {{field_name}} must name a real field on table), link (whether the message links back to the record, default true), and layout_html (an optional custom HTML layout). template_id and shared_with are read-only facts the writer computes; a draft carrying them changes nothing. |
advanced | delay_minutes (default 0, send immediately), cooldown_minutes (skip a repeat within this many minutes of the last one), rate_limit (cap how many sends in a window), digest_mode (batch sends instead of one per event). |
Dashboard
{ "name": "Loans Overview", "description": "Loan request volume and approval status.", "config": {}, "layout_type": "grid", "layout_config": {}, "page_type": "dashboard", "icon": "banknote", "visibility": "published", "is_public": true, "permissions": [ { "role_code": "loan_officer", "permission_type": "view" } ], "reports": [ { "name": "Loans by state", "source_table": "loan_requests", "visualization_type": "bar_chart", "group_by": "state" } ], "widgets": [ { "widget_type": "metric", "name": "Open Requests" } ]}| Key | Meaning |
|---|---|
name | The dashboard’s natural key within the tenant. Reinstalling with the same name updates it and replaces its reports and widgets rather than creating a second dashboard. |
description | A short description shown on the dashboard. |
config | Dashboard-level settings beyond layout. |
layout_type | grid (a fixed 10-column grid) or freeform. |
layout_config | Layout-specific settings, e.g. grid row height. |
page_type | dashboard or a specialised page type. |
icon | The dashboard’s icon name in navigation. |
visibility | published (every tenant user), shared (the roles in permissions), or private. Set automatically to shared when permissions is non-empty. |
is_public | Whether every tenant user can see this dashboard. Ignored (forced false) when permissions is non-empty. |
permissions | Role-scoped access beyond visibility: each entry needs role_code (or role_name) and permission_type (view or manage). |
reports | The dashboard’s own reports. See the Dashboard Report template below for one entry’s own keys. |
widgets | The dashboard’s own widgets (text, markdown, metric, link, image, …). See the Dashboard Widget template below for one entry’s own keys. |
Dashboard: reports[] (Dashboard Report)
| Key | Meaning |
|---|---|
name | The report’s display name. |
description | A short description of what the report shows. |
report_type | analytics (aggregated) or a list-style report. Defaults to analytics. |
source_type | Where the report reads from. Defaults to dynamic_table. |
source_table | The table this report reads from, for source_type dynamic_table. |
visualization_type | How the report renders: table, bar_chart, line_chart, pie_chart, or a KPI number. |
visualization_config | Chart-specific settings (axis labels, colors). |
group_by | The field this report groups rows by. |
aggregations | The aggregation(s) computed per group (count, sum, avg). |
filters | Row filters applied before grouping, in the same condition shape as a rule’s trigger_conditions. |
sort_by | The field (and direction) results sort by. |
config | Report-level settings beyond the keys above. |
position_config | This report’s grid position on the dashboard (column, row, width, height, on a 10-column grid). |
Dashboard: widgets[] (Dashboard Widget)
| Key | Meaning |
|---|---|
widget_type | text, markdown, metric, link, or image. Defaults to text. |
name | The widget’s display name. |
description | A short description of the widget. |
config | Widget-type-specific settings (the metric’s source query, the link’s URL, the image’s src). |
data | Static content for a text/markdown widget. |
position_config | This widget’s grid position on the dashboard (column, row, width, height; a KPI card is height 2). |
Navigation Module
{ "slug": "loans", "title": "Loans", "name": "Loans", "description": "Loan requests and approvals.", "icon": "banknote", "order": 10, "visibility": "public", "access": null, "allowed_roles": [ "Loan Officer" ], "role_codes": [], "roles": [], "menu_items": [ { "title": "Dashboard", "type": "dashboard", "dashboard_name": "Loans Overview", "order": 1 } ], "home": {}, "setup": { "checks": [] }, "names": { "de": "name (DE)" }, "descriptions": { "de": "description (DE)" }}| Key | Meaning |
|---|---|
slug | The module’s natural key within the tenant. Derived by slugifying name/title when absent; a draft rarely sets it directly. |
title | The module’s display name in the navigation rail. name is the blueprint spelling of the same field; either is read. |
name | Alias for title, read when title is absent. |
description | A short description of what this module contains. |
icon | The rail icon name, from the platform’s icon set. |
order | This module’s position among other modules, ascending. |
visibility | Who can see this module: public (default), or role_restricted (set automatically once allowed_roles names a role). Any other value, including all or hidden, is silently replaced with public at install, with a warning (M34, _resolve_navigation_access). |
access | An explicit access rule beyond visibility, evaluated the same way a table’s access is. |
allowed_roles | Role names that can see this module when visibility restricts it by role. Names, from the roles family, never a role’s code. |
role_codes | Alias for allowed_roles spelled by role code instead of name. |
roles | Alias for allowed_roles. |
menu_items | This module’s menu items, in order; the first is its dashboard. See the Navigation Menu Item template below for one entry’s own keys. |
home | This module’s own home/landing configuration, a nested blob this family stores but does not validate key-by-key. |
setup | This module’s Setup page declaration: checks, a list of {kind, …} readiness checks the platform already implements (see the guidance surface note in the connector program, section 3.6). A nested blob this family stores but does not validate key-by-key beyond requiring checks to be a list. |
names | Per-language override of name for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
descriptions | Per-language override of description for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
Navigation Module: menu_items[] (Navigation Menu Item)
| Key | Meaning |
|---|---|
title | The menu item’s display label. name is the alias read when title is absent. |
name | Alias for title, read when title is absent. |
slug | The item’s natural key within its module. Derived from title/name when absent. |
type | The item’s kind: table (a list view), dashboard, report, or link. |
config | Type-specific resolution config, a nested blob this family stores but does not validate key-by-key: table_name/linked_table for a table item, dashboard_id for a dashboard item, filters/views for either. |
table_name | The table this item lists, for a table-type item. Alias of linked_table. |
linked_table | Alias for table_name, read when table_name is absent. |
dashboard_name | The dashboard this item opens, for a dashboard-type item, resolved by name against the dashboards family. |
report_name | The report this item opens, for a report-type item. |
icon | This item’s own icon, overriding the module’s. |
path | An explicit route path, for a link-type item. |
description | A short description shown as this item’s tooltip. |
order | This item’s position among its module’s other items, ascending. |
is_visible | Whether this item shows in the rail. Defaults to true. |
titles | Per-language override of title for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
descriptions | Per-language override of description for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
Form
{ "name": "Loan Request Form", "description": "Request a new loan.", "target_table": "loan_requests", "layout": "single", "submit_button_text": "Submit Request", "success_message": "Your loan request has been submitted successfully.", "field_defaults": {}, "fields": [ { "field_name": "amount", "field_type": "number", "label": "Amount", "is_required": true, "order": 1 } ], "names": { "de": "name (DE)" }, "submit_button_texts": { "de": "submit button text (DE)" }, "success_messages": { "de": "success message (DE)" }}| Key | Meaning |
|---|---|
name | The form’s natural key within the tenant. Reinstalling with the same name updates the existing form and replaces its fields rather than creating a second form. |
description | A short description shown above the form. |
target_table | The table a submission creates a record in. |
layout | The form’s section layout: single (one column) or sectioned. |
submit_button_text | The submit button’s label. |
success_message | Shown after a successful submission. |
field_defaults | Values pre-filled onto this form’s own fields, keyed by field name. Does not declare a field; it only sets a default on one this form already has. |
fields | The form’s own fields, in order. See the Form Field template below for one entry’s own keys. |
names | Per-language override of name for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
submit_button_texts | Per-language override of submit_button_text for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
success_messages | Per-language override of success_message for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
Form: fields[] (Form Field)
| Key | Meaning |
|---|---|
field_source | Where this field’s value goes: mapped (a real column on target_table) or virtual (display-only, no column). |
field_name | The target table’s column this field maps to, when field_source is mapped. |
virtual_field_id | An id for this field when field_source is virtual. |
label | The field’s display label. Defaults to a title-cased field_name. |
help_text | Helper text shown under the field. |
placeholder | Placeholder text shown in an empty field. |
field_type | The input control: text, number, choice, date, reference, … |
options | Choice options for a choice-type field, when not read off the table’s own choice set. |
order | This field’s position on the form, ascending. |
section | The section this field belongs to, for a sectioned layout. |
width | This field’s column width: full or half. |
is_required | Whether the requester must fill this field in. |
is_visible | Whether this field shows on the form. Defaults to true. |
is_readonly | Whether this field shows but cannot be edited. |
validation | Extra validation rules for this field’s value. |
default_value | A fixed default value for this field. |
default_expression | An expression evaluated to default this field’s value. |
computation_formula | A formula computing this field’s value from other fields on the form. |
mapped_columns | For a field that writes more than one target column. |
conditional_visibility | Show or hide this field based on another field’s value. |
labels | Per-language override of label for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
help_texts | Per-language override of help_text for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
placeholders | Per-language override of placeholder for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
Action Button
{ "name": "Escalate", "table_name": "loan_requests", "label": "Escalate", "description": "Escalates this request to a senior approver.", "icon": "arrow-up", "color": "warning", "variant": "secondary", "placement": "toolbar", "action_type": "start_workflow", "script_body": null, "workflow_name": "Escalate Loan Request", "agent_slug": null, "skill_name": null, "wait_for_completion": false, "visibility_conditions": [ { "field": "state", "operator": "equals", "value": "submitted" } ], "requires_confirmation": true, "confirmation_message": "Escalate this request to a senior approver?", "execution_order": 1, "required_permission": null, "timeout_seconds": 30, "is_active": true, "labels": { "de": "label (DE)" }}| Key | Meaning |
|---|---|
name | The button’s natural key within the tenant together with table_name. Reinstalling with the same pair updates the existing button rather than creating a second one. |
table_name | The table this button appears on. |
label | The button’s visible text. |
description | A short description of what this button does, shown as a tooltip. |
icon | The button’s icon name, from the platform’s icon set. |
color | The button’s theme color token, never a hex value. |
variant | The button’s visual weight: primary, secondary or text. |
placement | Where the button shows on the record: toolbar or form_footer. |
action_type | What the button does: run_script, start_workflow, run_agent, or run_skill. |
script_body | The script to run, when action_type is run_script. See The scripting sandbox. |
workflow_name | The workflow to start, when action_type is start_workflow. |
agent_slug | The agent to run, when action_type is run_agent. |
skill_name | The skill to run, when action_type is run_skill. |
wait_for_completion | Whether the UI waits for the action to finish before refreshing the record. |
visibility_conditions | A list of {field, operator, value} objects (AND logic); the button shows only when every condition is true. field must be a real field on table_name; an unrunnable operator hides the button rather than always showing it. |
requires_confirmation | Whether clicking the button shows a confirmation dialog first. |
confirmation_message | The confirmation dialog’s message, when requires_confirmation is true. |
execution_order | This button’s position among other buttons on the same record, ascending. |
required_permission | A platform permission the acting user must hold to see the button, beyond table access. |
timeout_seconds | How long the platform waits for the action before reporting a timeout. |
is_active | Whether the button is enabled. Defaults to true. |
labels | Per-language override of label for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too. |
Seed Data
The top-level seed_data key is this object shape: table name to a list of rows, never a list of {table_name, records} components.
{ "loan_purposes": [ { "name": "Education" }, { "name": "Home Improvement" } ]}| Key | Meaning |
|---|---|
table_name | The table these rows seed into. Always installed, and re-converged on every reinstall or upgrade, unlike demo_data which is opt-in and sample data rather than reference data. |
records | The rows to seed, each {field_name: value, ...} against the target table’s own fields. A choice-valued field’s value must match a value the choices family declared for it exactly. |
7a. Scripts (business rules + execute_script workflow actions)
Scripts: whether inside a business rule’s script_body or a workflow
execute_script action: share the same sandboxed Python environment. Both
have the same injected helpers (record, previous, result, db,
notify, api, log, approval, ai, workflow, variables,
results) and the same allow-list of builtins and modules.
Authoritative reference: Scripting sandbox.
All scripts authored for blueprints: by humans, by the LLM, or via
wf.run_script(...) in the SDK: MUST conform to that guide. Key
conventions to carry through from it:
- Never call methods on
record.get(...)without guarding forNone: use(record.get('x') or 'default').lower(). - Never index into a
db.query(...)result without checking that it returned at least one row. - Never import blocked modules (
os,sys,subprocess,urllib,requests, etc.): allowed:datetime, json, math, random, re, collections, itertools, functools, decimal, hashlib, base64, uuid. - Calculated rules set
result['value'] = ...; validation rules setresult['is_valid']andresult['error_message']. notify.send(to=..., subject=..., body=..., channels=[...]): note this is the script-runtime API and usessubject/body. The SDK builderwf.notify(to=..., title=..., message=..., channels=[...])usestitle/message. The two coexist at different layers and do not need to be unified.
8. Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
Inline choice options on fields | Use choice_set: "table.field" referencing centralized choices |
| Custom approval tables | Use FlApprovalPolicy (platform-native) |
$ref:table:... syntax in workflows | Inline actual table/field names |
$ref:field:FIELD_NAME in action config (e.g., to, cc) | Use canonical recipient shorthand: "record.FIELD_NAME" or "{{record.FIELD_NAME}}". $ref: is not supported by any compiler or executor. |
Action-level condition on non-conditional actions | The field is silently ignored at runtime: the action fires unconditionally. Wrap the target action in a sibling action_type: "conditional" with config.conditions + config.conditionLogic, and express the branch as config.edges sourceHandles on the parent workflow (never config.true_actions, a dead dialect the workflow engine never reads). |
before_save trigger type | Use before_insert + before_update separately |
set_value / rollup rule types | Use action with action_config.set_fields or calculated with script_body |
| String fields for user references | Use reference type → users table |
| Process tables in seed data | Only seed configuration/reference tables |
Missing allowed_transitions on status | Every lifecycle status choice MUST define transitions |
| Colors on non-status choices | Only lifecycle status values get color |
| Icons/colors on table definitions | Icons only on navigation modules and menu items |
| Colors on metadata or KPI configs | Platform handles default styling |
Missing task_enabled on process tables | Every process/transactional table needs registration |
Duplicate number_prefix values | Each prefix must be unique across the blueprint |
| Dashboard without KPI row | Always start with 4 KPIs at y=0 |
| Navigation without dashboard first | First menu item = dashboard |
Rules without execution_order | Set explicit order (10=calc, 50=validation, 100=action) |
9. AI Chatbot Instructions (Flow Chatbot)
When the Flow AI Chatbot generates a blueprint, it MUST follow these steps:
Pre-Generation Checks
- Query existing tables:
list_tablesto avoid name collisions - Query existing fl_choice records: check
fl_choicesfor shared choice sets that can be reused (e.g., priority, urgency already exist from other modules) - Query existing fl_table_registry: check used
number_prefixvalues to avoid conflicts
Table Naming
- Use
module_prefix_entityformat:itsm_incidents,hr_employees,fin_invoices - Module prefix groups related tables:
itsm_*,hr_*,fin_* - Lowercase, snake_case, plural nouns
Number Prefix Rules
- 2-4 uppercase characters:
INC,CHG,PRB,CTSK,KBA,CI - Must be globally unique across tenant: check
fl_table_registryfirst - Mnemonic: should clearly identify the entity type
Choice Set Naming
- Format:
table_name.field_name: e.g.,itsm_incidents.status - This creates entries in
fl_choicesscoped to(tenant_id, table_name, field_name) - For shared choices across tables (e.g., all modules use same priority scale), each table still gets its own choice set but with identical values
Lifecycle Design Checklist
For every table with task_enabled: true:
- Define status choices with
allowed_transitions(state machine) - Set
show_progress_bar: trueandprogress_field: "status"on the table - Add timestamp rules:
resolved_at(when resolved),closed_at(when closed) - Add validation rules: gate transitions (require resolution_notes before resolved)
- Add notification rules: assigned, status changed, escalated
- Set
default_statematching the first status value’svalue
Generation Order
- Choices (all choice sets)
- Tables (with
choice_setreferences +task_enabledconfig) - Relationships (references between tables)
- Rules (calculated → validation → action, ordered by
execution_order) - Workflows (automation, scheduled tasks)
- Notifications (lifecycle event alerts)
- Approvals (if applicable)
- Dashboards (KPIs → charts → tables)
- Navigation (modules with dashboard-first items)
- Roles (admin → manager → user)
- Seed data (reference/config tables only)
10. Validation Checklist (Pre-Install)
- JSON valid: parseable with no syntax errors
- All
choice_setreferences resolve: every"table.field"has a matchingchoicesentry - All
reference_tablevalues exist: intablesarray orsystem_tables - All lifecycle status choices have
allowed_transitions: on task-enabled tables - All task-enabled tables have
number_prefix: unique across blueprint - All rule/workflow/notification/approval/dashboard
table_namevalues exist - Field types are valid: from the enumeration list
- Rule types are valid: only
calculated|validation|action|workflow_trigger - No circular workflow dependencies:
call_workflowchains don’t loop default_valuematches a choice value: on fields withchoice_setdefault_statematches a choice value: in the table’s status choice setprogress_fieldmatches a field name: on the same table- Exactly one
is_default: true: per choice set
11. Quality Scoring (Target: 85+)
| Dimension | Weight | Criteria |
|---|---|---|
| Tables | 25 pts | 3+ tables, proper fields, references, task_enabled, indexed fields |
| Choices | 10 pts | Centralized via choice_set, lifecycle transitions defined, defaults set |
| Table Registry | 5 pts | All process tables registered with unique prefixes, progress configured |
| Workflows | 15 pts | 2+ workflows, real triggers/actions, active status |
| Rules | 10 pts | 3+ rules, proper types, conditions, configs, execution_order set |
| Notifications | 10 pts | 2+ notifications, templates, proper recipients |
| Approvals | 10 pts | At least 1 if app has approval workflows |
| Dashboards | 5 pts | 1+ dashboard with KPIs + charts + tables |
| Navigation | 5 pts | Structured modules with dashboard-first, filtered views |
| Roles | 5 pts | 3+ roles (admin/manager/user minimum) |
Gold standard (itsm_core.json v3.0.0): ~95/100
12. The package is the source
A blueprint is authored as a PACKAGE (packages/<slug>/) and compiled into the
single JSON file the seeder reads. The compiled file is checked in so a pull
request shows what a tenant will receive, and CI fails when the two disagree.
Edit the package. Never edit the compiled file.
13. Keys
Every component a blueprint installs has a NATURAL KEY, and that key is how the platform finds the component again on an update, on a reinstall and on an uninstall. By default the key is derived from the component’s own display fields by the family’s installer:
This table is PINNED against the installers,
so a family whose key changes cannot leave this page saying the old one. Rows
are in install order; + means both fields, or means the first spelling the
component carries.
| Family | Key |
|---|---|
| choices | table_name + field_name |
| roles | name or code |
| tables | table_name |
| forms | name |
| dashboards | name |
| rules | name + table_name |
| approvals | name |
| workflows | name |
| notifications | name + table_name |
| detection_rules | rule_key |
| action_buttons | name + table_name |
| navigation | slug |
| seed_data | table_name |
| service_hub_items | name |
| skills | name |
| agents | slug |
Three rules follow from this.
- A key is permanent. Renaming a table’s display name, a workflow’s
nameor an agent’sslugin a later version is not by itself a rename to the platform: without more, it retires one component and creates another, which is how a renamed workflow once became two live workflows sending the same email. When a rename is really wanted, declare it (see below) and say so in the changelog. - A key must be readable. A component missing its key field is refused at compile time, because a component nothing can find again is created once on install and a second time on every update.
- A key is unique inside a package. Two components with one key are two producers of one row, and one of them would silently lose.
Declaring an explicit key, and a rename
A component may declare its own "key" string instead of relying on the
family’s default fields above. Once a component carries an explicit key,
its display fields (name, table_name, and so on) are free to change on
every later release without the platform ever reading that as a different
component: the explicit key is checked first, and the family’s own fields
are only the fallback for a component that declares none.
A rename that is genuinely wanted, of a component that has no explicit
key, is declared with "renamed_from": ["Old display name"] (one or more
previous names) on the renamed component. The installer matches the
renamed_from entry against what this tenant already has, and updates that
existing row in place under its new name, instead of leaving the old row
behind and creating a second, duplicate one. renamed_from is wired for
rules, notifications, forms, device scripts, action buttons and dashboards
today; a role, a group, a skill, or a service hub catalog item still needs
a genuine rename handled as a retire-and-recreate, called out in the
changelog. An approval policy does not accept renamed_from at all: a
business rule can call a policy by its derived code, and silently moving
that code onto a new name would misfire a rule nobody touched.
Removing a component (dropping it from the package, or switching it off between releases) retires every row of it across every registry this tenant has of the same package, not only the registry the update happens to run through, and an update that retires nothing never claims success for a component that was never there.
Deactivation-only updates
An update whose only change to a rule, workflow or notification (compared
with what this tenant already has installed) is switching is_active to
false, optionally alongside its description, is validated as a
deactivation, not as a full definition: the reference checks that would
refuse a table or field another part of the SAME release broke are skipped
for that component’s own key, because a component being turned off does
not need its references to still resolve. A component changing anything
else, even alongside being deactivated, is validated in full as usual.
14. Versioning
metadata.version is semver and it names exactly one content hash. The seeder
records an immutable release snapshot per version; publishing a DIFFERENT
definition under a version that already exists is REFUSED, and the fix is in
the file: bump the version.
- Bump the PATCH for wording, a template, a description.
- Bump the MINOR for a new component or a new field.
- Bump the MAJOR for anything that removes or renames a key.
Whether an update is available is a CONTENT fact, not a version comparison: the platform compares the tenant’s baseline with the catalog. The version string is what a person reads.
Every version needs a CHANGELOG.md entry, newest first, and the top entry’s
version must equal metadata.version. That text is what the update preview
shows before anyone clicks Update.
15. Upgrade
An update converges every component the package ships, by key, through the same applier the install used. Three things an author has to keep in mind.
- A component REMOVED from the package is withdrawn from the tenant, by key. Removing a component is a decision, not a cleanup.
- Adding a section to a package is not done until the installer, the seeder and the migrator all carry it. A section handled at install and not at upgrade ships green on new tenants and silently vanishes for every installed one.
- An update never advances a tenant onto a definition a fresh install would
refuse, so run the validator before shipping:
python compile_blueprint_package <package> --validate.
16. Customization
A tenant may edit some of what a package installs, and that edit must survive the next update. A package declares per family what a customer may change, and the platform holds a floor the declaration cannot lower: a security role, an external door, an approval ladder, a session mapping and an agent fence are never customer editable, whatever the package says.
When a customer has edited a component and the package changes the same component, the platform does not pick a winner silently: it keeps the tenant’s version and queues the difference for a person. Author with that in mind. A component people are expected to tune (a form, a dashboard, a notification template, a choice list) should be shipped in a shape that reads well when a tenant has changed part of it.
The overlay mechanism, the three way merge and the conflict review surface are owned by the platform, not by a blueprint.
17. Uninstall
Uninstall withdraws what the package installed, by key, and only what it installed. Two consequences for an author.
- Every component a package creates must be reachable by its key and carry its provenance, or the uninstall cannot find it. That is the same requirement as section 13, seen from the other end.
seed_datarows are NOT withdrawn. A reference row may be pointed at by records the customer created, so removing it on uninstall would break the customer’s own data. Ship reference rows asseed_dataand sample rows asdemo_data, and never put anything underseed_datathat the customer would want gone when the app is gone.
A shared physical table is never dropped on a tenant’s uninstall: the tenant’s rows go, the table stays while anything else references it.
18. Translations (the labels grammar)
Any display string a blueprint authors: a table’s display_name, a
field’s label/help_text/placeholder, a choice’s label, a
navigation module’s name or a menu item’s title, a notification’s
subject_template/body_template, a service_hub_item’s name/
short_description/description/category_name, a form field’s
label/help_text/placeholder: may carry a SIBLING map: the
mechanical plural of the same key, holding one entry per BCP 47 language
tag.
{"field_name": "priority", "ui_config": { "label": "Priority", "labels": {"de": "Priorität"}, "help_text": "...", "help_texts": {"de": "..."}}}{"value": "critical", "label": "Critical", "labels": {"de": "Kritisch"}}{"table_name": "itsm_incidents", "display_name": "Incidents", "display_names": {"de": "Störungen"}}{"title": "My Incidents", "titles": {"de": "Meine Störungen"}}{"name": "Report an Incident", "names": {"de": "Störung melden"}, "category_name": "IT Services", "category_names": {"de": "IT-Services"}}{"subject_template": "New Incident {{number}}", "subject_templates": {"de": "Neue Störung {{number}}"}}Rules.
- The plural key is the field’s OWN spelling plus
s:label->labels,help_text->help_texts,subject_template->subject_templates. The full grammar of which key is translatable per section lives in ONE place, shared by the validator and the installer. - A map’s language tag is validated by BCP 47 grammar, never against a hardcoded list of supported languages.
- Values NEVER translate. A choice’s
value(critical,resolved, …), a state name, an operator, a field name, a table name (the physical identifier): every fence, SLA terminal set, plan gate and business rule reads the VALUE, and a translated value silently breaks every one of them. Only thelabelhalf of a choice, never thevaluehalf, carries alabelsmap. - A map must not repeat the base string under the SOURCE language tag
(
en: a blueprint’s base columns are always authored in English): iflabelis"Priority",labelsmay not carry{"en": "Priority"}. The base key already IS the English text. - A plural key with no matching declared singular field on that node is
refused at validation as an “undeclared translation map”: a typo
(
"tooltips"where nothing declares a translatabletooltip) fails the build instead of silently doing nothing. - The installer writes every map through the ONE translation store,
never a language column added to a table/field/choice model. A tenant’s
own edit to a translated string always wins over a later blueprint
reinstall or upgrade (the store refuses to overwrite a
tenant-sourced row). - Every map’s text follows the same copy rule as the base columns: no em dash, no spaced double dash, in any language.