Skip to content

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.

ValueRollout stageWho sees it
public (default)AvailableEvery tenant’s Marketplace lists it.
stagedTestingStaged: visible to FlowLakes for testing before general release. Installed tenants still receive its upgrades, and a later flip to public reaches them.
control_planeHiddenUsed only by FlowLakes itself. It is never listed in a tenant Marketplace.
tenant_forknot a rollout stageA 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:

  1. Table-specific: (tenant_id, "itsm_incidents", "priority"): highest priority
  2. fl_tasks fallback: (tenant_id, "fl_tasks", "priority"): shared defaults
  3. 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

FieldRequiredTypeDescription
valueYesstringMachine-readable value (snake_case)
labelYesstringHuman-readable display text
sort_orderYesintegerDisplay order (1-based)
is_defaultNobooleanDefault selection (exactly one per choice set)
colorConditionalstringHex color: only on lifecycle status fields (see Icon/Color Policy below)
descriptionNostringTooltip or help text
allowed_transitionsConditionalstring[]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 options arrays on choice fields: always use choice_set
  • Every choice set MUST be defined in the choices array 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 (no icon or color on the blueprint itself)
  • Table definitions (no icon or color on 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 status on a task-enabled table (see Section 4)
  • The field is used as the progress_field in fl_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_transitions is an array of value strings 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 EnhancedRecordView renders a progress stepper from the progress_field choices

Design Guidelines

PatternExampleTransitions
Terminal state”closed”, “completed”[] or single reopen option
Reopenable terminal”closed” → can reopen["new"]
Linear progressiondraft → review → publishedEach points to next only
Branchingin_progress → resolved OR cancelledMultiple targets
Rollbackfailed → 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

FieldRequiredDefaultDescription
task_enabledYesN/AMust be true to trigger registration
number_prefixYesN/AUnique prefix for numbering (e.g., “INC”, “CHG”, “PRB”)
show_progress_barNofalseShow lifecycle stepper in record view
progress_fieldNonullField name used for stepper (usually "status")
default_stateNo"new"Default status value for new records
default_priorityNo"medium"Default priority value for new records
module_slugNonullGroups table under a module (e.g., “itsm”)
number_formatNo"{prefix}-{number:0{padding}d}"Number format template
number_paddingNo6Zero-padding width (6 → INC-000001)
categoryNo"general"Table category for filtering
accessNoabsent (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 inherits fl_tasks); the link lives on the child’s fl_tasks owner row. This is how a task record lists its sub-tasks.
  • reference: "link" names a reference field on the child whose reference_table is 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"]
}
]
KeyRequiredDefaultDescription
keyYesStable identifier, unique per table. Becomes fl_related_config.relation_name.
child_tableYesThe table whose rows are listed. Declared in this blueprint or a known platform table.
linkYesparent_task_id (hierarchy) or a reference field on the child targeting this table.
labelNokey, capitalisedThe list’s heading on the record page.
columnsNo[] (the surface’s default)Fields of the child, or of fl_tasks for a task-capable child, shown as columns.
allow_addNofalseShow the inline add row. Adding needs create on the child table and edit on the parent record.
quick_add_fieldsNo[]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.

TablePrefixExample Number
itsm_incidentsINCINC-000001
itsm_problemsPRBPRB-000001
itsm_changesCHGCHG-000001
itsm_change_tasksCTSKCTSK-000001
itsm_knowledge_articlesKBAKBA-000001
itsm_configuration_itemsCICI-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"
}
]
FieldRequiredDescription
tableYesA table this blueprint declares, or one it lists in system_tables[]
fieldYesA field on that table’s inheritance chain
valueYesThe value that selects the agent
agentYesThe 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 ..."
}
]
FieldRequiredDescription
capability_typeYesrest_endpoint, graph_query, mcp_server, email or web_fetch
nameFor multi-instance typesThe instance slug (^[a-z0-9_-]{1,120}$); omitted for a singleton type’s default row
providerNoThe provider label the row carries (microsoft_graph, generic_http)
configYesValidated by capability_service.validate_<type>_config, the runtime’s own grammar
noteNoThe door note body (kind door_note), bound to this connection

Rules:

  • config.tables is 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_id and credential are REFUSED by name. A blueprint ships no key material and opens nothing: the row installs DISABLED at posture observe with 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 on capability_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"]
}
]
FieldRequiredDescription
tableYesThe 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_tablesYesTables 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_notes carries no key of its own and nothing to retire. Uninstalling the package leaves the note exactly as it is.
  • The table key 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 of fl_table_registry.sessions_enabled and .session_config. Applying sets sessions_enabled to 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_mappings rule 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_id means 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 choices entries before any table references them via choice_set
  • Identify entity types: configuration/reference vs. process/transactional
  • Every process table gets task_enabled: true + number_prefix
  • Every entity with lifecycle gets a status field (type: choice) with allowed_transitions
  • Tables with task_enabled: true get show_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 has is_default: true
  • Computed/readonly fields include ui_config: {"readonly": true}
  • Group fields into ui_config.section for form layout (Details, Classification, Assignment, Schedule, Resolution, etc.). Sections render in the order their first field appears; Resolution is the record view’s resolution block; a hidden field needs none. A table of six fields or fewer is one Details section.
  • A table with parent_table: "fl_tasks" declares EVERY inheritable parent field it shows: with a section, or with "visible": false when 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: true on: 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), valid trigger_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_order explicitly (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 numbered stages

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} (add role_name too: 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/queues h=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 both subject_template and body_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:action or table_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 | choice

Rule Types

rule_typetrigger_typeConfig Pattern
calculatedbefore_insert, before_updatescript_body or formula + applies_to_field
validationbefore_insert, before_updateconfig: {validation, field, error_message}
actionbefore_insert, before_update, after_insert, after_updateaction_config: {set_fields: {}}: or a named action: spawn_tasks, run_judgment, intercept_duplicate, cascade_duplicates
workflow_triggerafter_insert, after_updatetriggers 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_variable

Approval 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:

ShorthandMeaningResolves to
"role:ROLE_NAME"All users with this rolerecipient_type: "role", recipient_config: {"role_names": ["ROLE_NAME"]}
"group:GROUP_NAME"All users in this grouprecipient_type: "group", recipient_config: {"group_ids": ["GROUP_NAME"]}
"record.FIELD_NAME"User in a field on the triggering recordrecipient_type: "field_value", recipient_config: {"field": "FIELD_NAME"}
"{{record.FIELD_NAME}}"Templated field reference (equivalent)same as above
"user@example.com"Specific email addressrecipient_type: "email", recipient_config: {"emails": [...]}
"<uuid>"Specific user by IDrecipient_type: "user", recipient_config: {"user_ids": [...]}
["a", "b"]Merge list of recipients of same typecompiler 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 as to
  • template_id: reference a FlNotificationTemplate by 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"
}
}
KeyMeaning
nameThe 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.
descriptionThe long description shown on the item’s own detail page.
short_descriptionThe one line summary shown on the catalog tile itself.
category_nameThe 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_descriptionThe category’s own description, stamped only the first time an item’s install creates that category for the tenant.
category_iconThe category’s own icon, stamped only the first time an item’s install creates that category for the tenant.
form_nameThe 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_tableThe table a submission through this item writes its record into.
iconThe icon shown on the item’s own portal tile (separate from the category’s icon).
orderThe tile’s sort position within its category, ascending.
keywordsExtra search terms the portal’s catalog search matches against, besides the name and description.
is_activeWhether 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_featuredWhether the tile is promoted to the portal’s featured section.
requires_approvalWhether 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_configDeprecated 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_hoursThe SLA target, in hours, for submissions through this item, before any priority based override.
visibilityWho can see the tile: “all” for every authenticated user, or “group” / “role” to narrow it further with visible_to.
visible_toThe group or role ids the tile is restricted to when visibility is “group” or “role”; ignored when visibility is “all”.
field_defaultsValues 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_tasksThe sub-task specs spawned once a submission reaches this item’s spawn point, passed as-is to the shared task spawner.
fulfillment_spawn_onWhen fulfillment_tasks spawn: “on_create” (immediately) or “on_approval” (the default; when the request reaches in_progress).
approval_policyThe name of an approval policy (the approvals family) this item’s requests route through; resolved to approval_policy_id at install time.
approval_policy_idThe resolved id of the bound approval policy. Normally left out and resolved automatically from approval_policy by name.
namesPer-language overrides of name; a language missing from this map falls back to name itself.
short_descriptionsPer-language overrides of short_description.
descriptionsPer-language overrides of description.
category_namesPer-language overrides of category_name, read only the first time this item’s install creates the category.
category_descriptionsPer-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"
]
}
KeyMeaning
nameThe 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.
purposeA short sentence on who holds this role and why, 160 characters or fewer.
record_grantsWhat 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_capabilitiesPlatform 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"
}
KeyMeaning
nameThe 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.
descriptionA short description of who this group is for and what it routes.
membersThis 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.
leaderThis 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": []
}
]
}
KeyMeaning
table_nameThe table this choice set belongs to, together with field_name.
field_nameThe field this choice set fills. For a table’s lifecycle, this is state.
valuesThe 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"
}
KeyMeaning
table_nameThe 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_nameThe table’s display name, shown in navigation and forms.
descriptionA short description of what this table stores.
iconThe table’s icon name, from the platform’s icon set.
fieldsThe 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_enabledWhether 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_tableAn 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_namesPer-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.
descriptionsPer-language override of description for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too.
accessAn explicit row-access rule for this table, beyond ordinary role permissions.
routingService-request routing config for this table, when it is a service hub target.
child_listsRelated-record lists to show on this table’s own record page, pulled from a child table.
list_columnsThe default columns a list view of this table shows.
aliasesAlternate 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).
categoryThe task registry category this table’s records file under, for a task_enabled table.
colorThe task registry’s accent color token for this table, never a hex value.
default_priorityA new record’s starting priority, for a task_enabled table that uses priority.
default_stateA new record’s starting state, which must be one of this table’s own declared state choice values.
module_slugThe navigation module this table’s records are filed under, by slug.
number_formatThe template for this table’s generated record number.
number_paddingHow many digits the generated number pads to.
number_prefixThis table’s record-number prefix. Must be unique across every task_enabled table in the tenant.
progress_fieldThe field holding this table’s lifecycle progress. Always state on a task_enabled table; the validator refuses any other value.
show_progress_barWhether the record page shows a progress bar driven by progress_field.
table_typephysical (a real dynamic table) or virtual (a view over other tables). Defaults to physical.

Table: fields[] (Table Field)

KeyMeaning
field_nameThe field’s column name. name is the blueprint alias read when field_name is absent.
nameAlias for field_name, read when field_name is absent.
field_typeThe column’s data type. type is the blueprint alias read when field_type is absent.
typeAlias for field_type, read when field_type is absent.
field_lengthThe 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_requiredWhether the column disallows null.
is_uniqueWhether the column is unique, scoped per tenant (never a bare unique across every tenant’s rows).
is_indexedWhether the column is indexed. is_searchable is the blueprint alias read when is_indexed is absent.
is_searchableAlias for is_indexed, read when is_indexed is absent.
default_valueThe column’s default value for a new row.
reference_tableThe table a reference-type field points at.
reference_fieldThe column on reference_table this field resolves to. Defaults to id.
validation_rulesExtra validation rules for this field’s value.
ui_configForm-rendering config for this field (widget, columns, …).
choice_setThe 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_modeWho 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
}
KeyMeaning
table_nameThe table this rule runs on: installed, or declared in this draft’s own tables phase.
nameThe 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.
descriptionA short description of what this rule does.
is_activeWhether the rule is enabled. Defaults to true.
eventsWhen this rule runs: created, updated, or created_or_updated.
matchWhether all or any of the conditions must hold.
conditionsConditions 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.
logicsteps (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).
stepsAn ordered list of actions this rule performs when logic is steps: set_field, assign, stop_save, request_approval, create_tasks, start_workflow.
rule_typeFor a brand-new script rule only: calculated, validation, or action.
script_bodyFor 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": {}
}
KeyMeaning
nameThe policy’s natural key within the tenant. Reinstalling with the same name updates the existing policy rather than creating a second one.
tableThe ONE record this policy asks about: installed, or declared in this draft’s own tables phase.
descriptionA short description of what this policy approves.
is_activeWhether the policy is enabled. Defaults to true.
momentWhen 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.
stepsAn 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).
thenWhat 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).
advancedcode, 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."
}
]
}
KeyMeaning
table_nameThe table this workflow runs on: installed, or declared in this draft’s own tables phase.
nameThe 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.
descriptionA short description of what this workflow does.
is_activeWhether the workflow is on. On an update, leaving it out keeps the stored status. A new workflow must set it.
whenThe 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.
matchWhether all or any of the conditions must hold.
conditionsConditions that must hold for this workflow to start. Every field must be a real field on table_name.
stepsAn 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": {}
}
KeyMeaning
nameThe 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.
tableThe table this rule watches: installed, or declared in this draft’s own tables phase.
onWhether the rule is enabled. Defaults to true.
momentWhen 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.
whoONE 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.
howThe channels this rule sends on: in_app, email, or both. Defaults to [“in_app”].
whatWhat 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.
advanceddelay_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"
}
]
}
KeyMeaning
nameThe 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.
descriptionA short description shown on the dashboard.
configDashboard-level settings beyond layout.
layout_typegrid (a fixed 10-column grid) or freeform.
layout_configLayout-specific settings, e.g. grid row height.
page_typedashboard or a specialised page type.
iconThe dashboard’s icon name in navigation.
visibilitypublished (every tenant user), shared (the roles in permissions), or private. Set automatically to shared when permissions is non-empty.
is_publicWhether every tenant user can see this dashboard. Ignored (forced false) when permissions is non-empty.
permissionsRole-scoped access beyond visibility: each entry needs role_code (or role_name) and permission_type (view or manage).
reportsThe dashboard’s own reports. See the Dashboard Report template below for one entry’s own keys.
widgetsThe 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)

KeyMeaning
nameThe report’s display name.
descriptionA short description of what the report shows.
report_typeanalytics (aggregated) or a list-style report. Defaults to analytics.
source_typeWhere the report reads from. Defaults to dynamic_table.
source_tableThe table this report reads from, for source_type dynamic_table.
visualization_typeHow the report renders: table, bar_chart, line_chart, pie_chart, or a KPI number.
visualization_configChart-specific settings (axis labels, colors).
group_byThe field this report groups rows by.
aggregationsThe aggregation(s) computed per group (count, sum, avg).
filtersRow filters applied before grouping, in the same condition shape as a rule’s trigger_conditions.
sort_byThe field (and direction) results sort by.
configReport-level settings beyond the keys above.
position_configThis report’s grid position on the dashboard (column, row, width, height, on a 10-column grid).

Dashboard: widgets[] (Dashboard Widget)

KeyMeaning
widget_typetext, markdown, metric, link, or image. Defaults to text.
nameThe widget’s display name.
descriptionA short description of the widget.
configWidget-type-specific settings (the metric’s source query, the link’s URL, the image’s src).
dataStatic content for a text/markdown widget.
position_configThis widget’s grid position on the dashboard (column, row, width, height; a KPI card is height 2).
{
"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)"
}
}
KeyMeaning
slugThe module’s natural key within the tenant. Derived by slugifying name/title when absent; a draft rarely sets it directly.
titleThe module’s display name in the navigation rail. name is the blueprint spelling of the same field; either is read.
nameAlias for title, read when title is absent.
descriptionA short description of what this module contains.
iconThe rail icon name, from the platform’s icon set.
orderThis module’s position among other modules, ascending.
visibilityWho 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).
accessAn explicit access rule beyond visibility, evaluated the same way a table’s access is.
allowed_rolesRole names that can see this module when visibility restricts it by role. Names, from the roles family, never a role’s code.
role_codesAlias for allowed_roles spelled by role code instead of name.
rolesAlias for allowed_roles.
menu_itemsThis module’s menu items, in order; the first is its dashboard. See the Navigation Menu Item template below for one entry’s own keys.
homeThis module’s own home/landing configuration, a nested blob this family stores but does not validate key-by-key.
setupThis 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.
namesPer-language override of name for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too.
descriptionsPer-language override of description for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too.
KeyMeaning
titleThe menu item’s display label. name is the alias read when title is absent.
nameAlias for title, read when title is absent.
slugThe item’s natural key within its module. Derived from title/name when absent.
typeThe item’s kind: table (a list view), dashboard, report, or link.
configType-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_nameThe table this item lists, for a table-type item. Alias of linked_table.
linked_tableAlias for table_name, read when table_name is absent.
dashboard_nameThe dashboard this item opens, for a dashboard-type item, resolved by name against the dashboards family.
report_nameThe report this item opens, for a report-type item.
iconThis item’s own icon, overriding the module’s.
pathAn explicit route path, for a link-type item.
descriptionA short description shown as this item’s tooltip.
orderThis item’s position among its module’s other items, ascending.
is_visibleWhether this item shows in the rail. Defaults to true.
titlesPer-language override of title for this item, an object of {language_code: translated_value}; install always keeps the base field’s own language too.
descriptionsPer-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)"
}
}
KeyMeaning
nameThe 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.
descriptionA short description shown above the form.
target_tableThe table a submission creates a record in.
layoutThe form’s section layout: single (one column) or sectioned.
submit_button_textThe submit button’s label.
success_messageShown after a successful submission.
field_defaultsValues 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.
fieldsThe form’s own fields, in order. See the Form Field template below for one entry’s own keys.
namesPer-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_textsPer-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_messagesPer-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)

KeyMeaning
field_sourceWhere this field’s value goes: mapped (a real column on target_table) or virtual (display-only, no column).
field_nameThe target table’s column this field maps to, when field_source is mapped.
virtual_field_idAn id for this field when field_source is virtual.
labelThe field’s display label. Defaults to a title-cased field_name.
help_textHelper text shown under the field.
placeholderPlaceholder text shown in an empty field.
field_typeThe input control: text, number, choice, date, reference, …
optionsChoice options for a choice-type field, when not read off the table’s own choice set.
orderThis field’s position on the form, ascending.
sectionThe section this field belongs to, for a sectioned layout.
widthThis field’s column width: full or half.
is_requiredWhether the requester must fill this field in.
is_visibleWhether this field shows on the form. Defaults to true.
is_readonlyWhether this field shows but cannot be edited.
validationExtra validation rules for this field’s value.
default_valueA fixed default value for this field.
default_expressionAn expression evaluated to default this field’s value.
computation_formulaA formula computing this field’s value from other fields on the form.
mapped_columnsFor a field that writes more than one target column.
conditional_visibilityShow or hide this field based on another field’s value.
labelsPer-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_textsPer-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.
placeholdersPer-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)"
}
}
KeyMeaning
nameThe 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_nameThe table this button appears on.
labelThe button’s visible text.
descriptionA short description of what this button does, shown as a tooltip.
iconThe button’s icon name, from the platform’s icon set.
colorThe button’s theme color token, never a hex value.
variantThe button’s visual weight: primary, secondary or text.
placementWhere the button shows on the record: toolbar or form_footer.
action_typeWhat the button does: run_script, start_workflow, run_agent, or run_skill.
script_bodyThe script to run, when action_type is run_script. See The scripting sandbox.
workflow_nameThe workflow to start, when action_type is start_workflow.
agent_slugThe agent to run, when action_type is run_agent.
skill_nameThe skill to run, when action_type is run_skill.
wait_for_completionWhether the UI waits for the action to finish before refreshing the record.
visibility_conditionsA 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_confirmationWhether clicking the button shows a confirmation dialog first.
confirmation_messageThe confirmation dialog’s message, when requires_confirmation is true.
execution_orderThis button’s position among other buttons on the same record, ascending.
required_permissionA platform permission the acting user must hold to see the button, beyond table access.
timeout_secondsHow long the platform waits for the action before reporting a timeout.
is_activeWhether the button is enabled. Defaults to true.
labelsPer-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"
}
]
}
KeyMeaning
table_nameThe 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.
recordsThe 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 for None : 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 set result['is_valid'] and result['error_message'].
  • notify.send(to=..., subject=..., body=..., channels=[...]): note this is the script-runtime API and uses subject/body. The SDK builder wf.notify(to=..., title=..., message=..., channels=[...]) uses title/message. The two coexist at different layers and do not need to be unified.

8. Anti-Patterns

Anti-PatternCorrect Approach
Inline choice options on fieldsUse choice_set: "table.field" referencing centralized choices
Custom approval tablesUse FlApprovalPolicy (platform-native)
$ref:table:... syntax in workflowsInline 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 actionsThe 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 typeUse before_insert + before_update separately
set_value / rollup rule typesUse action with action_config.set_fields or calculated with script_body
String fields for user referencesUse reference type → users table
Process tables in seed dataOnly seed configuration/reference tables
Missing allowed_transitions on statusEvery lifecycle status choice MUST define transitions
Colors on non-status choicesOnly lifecycle status values get color
Icons/colors on table definitionsIcons only on navigation modules and menu items
Colors on metadata or KPI configsPlatform handles default styling
Missing task_enabled on process tablesEvery process/transactional table needs registration
Duplicate number_prefix valuesEach prefix must be unique across the blueprint
Dashboard without KPI rowAlways start with 4 KPIs at y=0
Navigation without dashboard firstFirst menu item = dashboard
Rules without execution_orderSet 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

  1. Query existing tables: list_tables to avoid name collisions
  2. Query existing fl_choice records: check fl_choices for shared choice sets that can be reused (e.g., priority, urgency already exist from other modules)
  3. Query existing fl_table_registry: check used number_prefix values to avoid conflicts

Table Naming

  • Use module_prefix_entity format: 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_registry first
  • 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_choices scoped 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:

  1. Define status choices with allowed_transitions (state machine)
  2. Set show_progress_bar: true and progress_field: "status" on the table
  3. Add timestamp rules: resolved_at (when resolved), closed_at (when closed)
  4. Add validation rules: gate transitions (require resolution_notes before resolved)
  5. Add notification rules: assigned, status changed, escalated
  6. Set default_state matching the first status value’s value

Generation Order

  1. Choices (all choice sets)
  2. Tables (with choice_set references + task_enabled config)
  3. Relationships (references between tables)
  4. Rules (calculated → validation → action, ordered by execution_order)
  5. Workflows (automation, scheduled tasks)
  6. Notifications (lifecycle event alerts)
  7. Approvals (if applicable)
  8. Dashboards (KPIs → charts → tables)
  9. Navigation (modules with dashboard-first items)
  10. Roles (admin → manager → user)
  11. Seed data (reference/config tables only)

10. Validation Checklist (Pre-Install)

  1. JSON valid: parseable with no syntax errors
  2. All choice_set references resolve: every "table.field" has a matching choices entry
  3. All reference_table values exist: in tables array or system_tables
  4. All lifecycle status choices have allowed_transitions: on task-enabled tables
  5. All task-enabled tables have number_prefix: unique across blueprint
  6. All rule/workflow/notification/approval/dashboard table_name values exist
  7. Field types are valid: from the enumeration list
  8. Rule types are valid: only calculated|validation|action|workflow_trigger
  9. No circular workflow dependencies: call_workflow chains don’t loop
  10. default_value matches a choice value: on fields with choice_set
  11. default_state matches a choice value: in the table’s status choice set
  12. progress_field matches a field name: on the same table
  13. Exactly one is_default: true: per choice set

11. Quality Scoring (Target: 85+)

DimensionWeightCriteria
Tables25 pts3+ tables, proper fields, references, task_enabled, indexed fields
Choices10 ptsCentralized via choice_set, lifecycle transitions defined, defaults set
Table Registry5 ptsAll process tables registered with unique prefixes, progress configured
Workflows15 pts2+ workflows, real triggers/actions, active status
Rules10 pts3+ rules, proper types, conditions, configs, execution_order set
Notifications10 pts2+ notifications, templates, proper recipients
Approvals10 ptsAt least 1 if app has approval workflows
Dashboards5 pts1+ dashboard with KPIs + charts + tables
Navigation5 ptsStructured modules with dashboard-first, filtered views
Roles5 pts3+ 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.

FamilyKey
choicestable_name + field_name
rolesname or code
tablestable_name
formsname
dashboardsname
rulesname + table_name
approvalsname
workflowsname
notificationsname + table_name
detection_rulesrule_key
action_buttonsname + table_name
navigationslug
seed_datatable_name
service_hub_itemsname
skillsname
agentsslug

Three rules follow from this.

  1. A key is permanent. Renaming a table’s display name, a workflow’s name or an agent’s slug in 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.
  2. 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.
  3. 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_data rows 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 as seed_data and sample rows as demo_data, and never put anything under seed_data that 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 the label half of a choice, never the value half, carries a labels map.
  • A map must not repeat the base string under the SOURCE language tag (en: a blueprint’s base columns are always authored in English): if label is "Priority", labels may 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 translatable tooltip) 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.