Your First Governed Change

You have a running AuraBoot with a CRM in it. Ten minutes from here you will have changed that CRM, watched the platform stop you doing something you didn't declare, and read the audit record of everything you did.

That is the whole product in one sitting. Nothing here is a toy: it is the same path a real feature takes.

Before you start — finish the Quick Start. You need a stack that is bootstrapped and has the plugins imported, and you need to be able to log in.

Every command below assumes a shell variable with an admin token:

BACKEND=http://localhost:3000   # the URL you open in the browser; only this port is published
JWT=$(curl -s -X POST $BACKEND/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"Test2026x"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["jwt"])')

1. Look at what you already have

Open CRM → Leads. Create a lead through the UI. It works, and nobody wrote that page.

The list, its columns, its form and its validation all came from one declaration — plugins/crm-starter/config/fields/crm_lead.json — plus a binding that says which fields the model actually uses. That is the first idea: the model is a file in Git, and the UI is derived from it.

2. Add a field

Sales want to record a budget. Open plugins/crm-starter/config/fields/crm_lead.json and append:

{
  "code": "crm_lead_budget",
  "displayName:en": "Budget",
  "displayName:zh-CN": "预算",
  "dataType": "decimal",
  "constraints": { "required": false },
  "feature": { "sortable": true }
}

A field that exists is not a field the model uses. Bind it — plugins/crm-starter/config/bindings/crm_lead.json:

{
  "modelCode": "crm_lead",
  "fieldCode": "crm_lead_budget",
  "sequence": 15,
  "required": false,
  "visible": true,
  "editable": true,
  "displayConfig": { "sortable": true }
}

Re-import the plugin:

curl -s -X POST $BACKEND/api/plugins/import/import-directory-sync \
  -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"path":"/app/plugins/crm-starter","conflictStrategy":"OVERWRITE"}'

Two things just happened without you asking:

  • The database changed. mt_crm_lead has a new crm_lead_budget numeric column. You wrote no migration.
  • The UI changed. Reload CRM → Leads. Budget is in the form.

3. Watch the platform refuse to write it

Create a lead with a budget:

curl -s -X POST $BACKEND/api/meta/commands/execute/crm:create_lead \
  -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"payload":{"crm_lead_company":"Contoso Ltd",
                  "crm_lead_contact_name":"Bo Chen",
                  "crm_lead_budget":50000},
       "operationType":"CREATE"}'

The command reports success — "code":"0", "phaseReached":"completion". Look at the row:

LEAD-20260713-002 | Contoso Ltd | budget = (empty)

The budget is gone. Not an error. Not a warning. Dropped.

This is the second idea, and it is the one most platforms get wrong. The field exists on the model, but crm:create_lead never said it would accept it. Open plugins/crm-starter/config/commands/crm_lead.json:

{
  "code": "crm:create_lead",
  "type": "create",
  "inputFields": [
    "crm_lead_company", "crm_lead_contact_name", "crm_lead_contact_phone",
    "crm_lead_contact_email", "crm_lead_source", "crm_lead_industry",
    "crm_lead_score", "crm_lead_campaign", "owner_type", "owner_id",
    "crm_lead_requirement"
  ],
  "permissions": ["crm.lead.manage"],
  "autoSetFields": {
    "crm_lead_code":   { "strategy": "auto_generate", "pattern": "LEAD-{yyyyMMdd}-{seq}" },
    "crm_lead_status": { "strategy": "fixed_value", "value": "new" }
  }
}

inputFields is a whitelist, not documentation. Anything not on it is discarded — which is exactly what you want the day someone POSTs {"crm_lead_status":"won"} at your API. Note autoSetFields too: crm_lead_code is generated, so a client cannot choose it, no matter what it sends.

Add "crm_lead_budget" to inputFields, re-import, and create the lead again:

LEAD-20260713-003 | Fabrikam Inc | budget = 50000.00

Two declarations, two jobs. The model says what the thing is. The command says what may be written, by whom, and what the system fills in itself.

4. Notice who is allowed

You already saw it, on the same command:

"permissions": ["crm.lead.manage"]

That is not a UI-level check that a determined caller can walk around. It is the command's own contract, enforced in the pipeline — and the pipeline is the only way to write. A person clicking Save, an automation, a BPM node, an AI agent calling a tool: all four arrive here, and all four are measured against that one line.

There is no second path. That is the point of the whole design.

5. Read what you did

Every execution — allowed or refused — leaves a record.

SELECT command_code, success, phase_reached, execution_time_ms, request_payload
FROM   ab_command_audit_log
ORDER  BY created_at DESC
LIMIT  3;
crm:create_lead | t | completion |  24ms | {"crm_lead_code": "LEAD-20260713-003",
                                           "crm_lead_budget": 50000,
                                           "crm_lead_status": "new",
                                           "crm_lead_company": "Fabrikam Inc", …}
crm:create_lead | t | completion |  87ms | …
crm:create_lead | t | completion | 532ms | …

The payload the pipeline actually accepted, who sent it, how long it took, how far it got. You did not turn this on. You cannot turn it off for one caller and not another, because there is only one caller — the pipeline.

What you just proved

The model is a fileYou added a field; the column and the form followed. No migration, no page.
The command is a contractIt silently refused a value it never agreed to accept — and told the truth in the audit log about what it did take.
The permission is on the commandNot on the button. People, automations and agents all pass the same line.
The audit is not a featureIt is a property of there being exactly one way in.

Next