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_leadhas a newcrm_lead_budget numericcolumn. 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.00Two 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"}'Sam — tenant_member | 403 Access forbidden |
Rita — crm_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 file | You added a field; the column and the form followed. No migration, no page. |
| The command is a contract | It 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 command | Not on the button. People, automations and agents all pass the same line. |
| The audit is not a feature | It is a property of there being exactly one way in — for everything that gets in. |
Next
- Create Your First Model — the same loop, but you author the model instead of editing one.
- Permissions — the five layers, and how a role reaches a command.
- Command Pipeline — the phases you just saw in
phase_reached. - Build a CRM — seven chapters, ending in a released module.