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":"admin@auraboot.com","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. Get refused

You already saw the rule, on the same command:

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

Reading it is not the same as feeling it. Make two colleagues and watch the platform tell one of them no.

# Sam: the tenant baseline. No CRM grants.
curl -s -X POST $BACKEND/api/meta/commands/execute/admin:create_member \
  -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"payload":{"name":"Sam Reader","email":"sam@example.com",
                  "password":"Test2026x","roleCodes":["tenant_member"]},
       "operationType":"CREATE"}'

# Rita: sales.
curl -s -X POST $BACKEND/api/meta/commands/execute/admin:create_member \
  -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
  -d '{"payload":{"name":"Rita Sales","email":"rita@example.com",
                  "password":"Test2026x","roleCodes":["crm_sales"]},
       "operationType":"CREATE"}'

Log in as each of them and run the same command with the same payload:

SAM=$(curl -s -X POST $BACKEND/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"sam@example.com","password":"Test2026x"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["jwt"])')

curl -s -X POST $BACKEND/api/meta/commands/execute/crm:create_lead \
  -H "Authorization: Bearer $SAM" -H 'Content-Type: application/json' \
  -d '{"payload":{"crm_lead_company":"Sam Co","crm_lead_contact_name":"Sam"},
       "operationType":"CREATE"}'
Samtenant_member403 Access forbidden
Ritacrm_sales"code":"0", phaseReached: completion

Same command. Same payload. The difference is the role, and the role reaches the command because the command declares what it requires.

That is not a UI-level check 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 meet that one line.

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

5. Read what you did

Every execution that reaches the pipeline 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.

Sam's 403 is not in there. The permission check refuses him before the pipeline runs, so the pipeline's audit log never sees the attempt. Worth knowing before you go looking for it: today this table answers "what was done", not "what was attempted".

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 — for everything that gets in.

Next