Your First Governed Change
You have a running AuraBoot with the Showcase plugin in it — the quick start imported it. Ten minutes from here you will have changed that plugin, watched the platform stop you doing something you didn't declare, met the two gates every write passes, and read the audit record of everything that got through.
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 browser URL; only this port is published
ADMIN_EMAIL=[email protected] # use the account configured during bootstrap
read -r -s -p "Administrator password: " ADMIN_PASSWORD; printf '\n'
JWT=$(AURA_CORE_BASE_URL="$BACKEND" ADMIN_EMAIL="$ADMIN_EMAIL" \
ADMIN_PASSWORD="$ADMIN_PASSWORD" \
python3 scripts/application/core-admin-session.py)Run this from the cloned AuraBoot repository, using a version that includes scripts/application/core-admin-session.py. The helper logs in, selects exactly one business space through the real tenant-selection API and returns its token. A bootstrap administrator can also belong to the platform space, so a bare login token is not enough. If several business spaces exist, set AURA_ADMIN_TENANT_ID explicitly to the intended business tenant. Keep the token and password private.
1. Look at what you already have
Create a Showcase record and a monthly metric for it:
curl -s -X POST $BACKEND/api/meta/commands/execute/sc:create_showcase \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_name":"Verify Widget","sc_description":"my first governed change",
"sc_quantity":10,"sc_price":99.5},"operationType":"CREATE"}'{"code":"0","data":{"commandCode":"sc:create_showcase",
"phaseReached":"completed",
"data":{"recordPid":"01M3GK5TCVTRVQH66H8BS0ZE8E",
"showcase_all_fields_inserted":1},
"executionTimeMs":133}}Copy data.data.recordPid — the metric below hangs off it. The list, its columns and its form all came from declarations — plugins/showcase/config/models.json plus fields, bindings and commands beside it. That is the first idea: the model is a file in Git, and the UI is derived from it. (The same pages render in Field Showcase → All Field Types if you prefer clicking to curling.)
Now record a monthly metric against it:
curl -s -X POST $BACKEND/api/meta/commands/execute/sc:create_monthly_metric \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_showcase_pid":"<your recordPid>","sc_month":9,
"sc_revenue":1200,"sc_cost":800,"sc_units":40,
"sc_metric_remark":"baseline"},"operationType":"CREATE"}'It works, and nobody wrote that page or that endpoint.
2. Add a field
Sales want to record a target next to each month's actuals. Open plugins/showcase/config/fields/sc_monthly_metric.json and append:
{
"code": "sc_metric_target",
"displayName:en": "Target",
"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/showcase/config/bindings/sc_monthly_metric.json:
{
"modelCode": "sc_monthly_metric",
"fieldCode": "sc_metric_target",
"sequence": 7,
"required": false,
"visible": true,
"editable": true,
"displayConfig": { "sortable": true }
}Re-import the plugin so the platform picks your files up. The path is where the backend sees the plugin directory — inside the Docker stack that is /app/plugins/showcase; running the backend on the host, it is <repo>/plugins/showcase:
curl -s -X POST $BACKEND/api/plugins/import/import-directory-sync \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"path":"/app/plugins/showcase","conflictStrategy":"OVERWRITE"}'The imported declaration changes the model metadata:
- The database changed.
mt_sc_monthly_metrichas a newsc_metric_target numericcolumn. You wrote no migration. - The field is bound to
sc_monthly_metric. It does not add Target to the separateshowcase_all_fieldsform. This tutorial verifies the monthly metric through its command and persisted row; add an explicit page field declaration if you want to expose it in a custom page.
3. Watch the platform refuse to write it
Create a metric with a target:
curl -s -X POST $BACKEND/api/meta/commands/execute/sc:create_monthly_metric \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_showcase_pid":"<your recordPid>","sc_month":8,
"sc_revenue":900,"sc_cost":700,"sc_units":30,
"sc_metric_target":777},"operationType":"CREATE"}'The command reports success — "code":"0", "phaseReached":"completed". Look at the row:
SELECT sc_month, sc_revenue, sc_metric_target
FROM mt_sc_monthly_metric ORDER BY created_at DESC LIMIT 1; 8 | 900.00 | (empty)The target 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 sc:create_monthly_metric never said it would accept it. Open plugins/showcase/config/commands/sc_monthly_metric.json:
{
"code": "sc:create_monthly_metric",
"type": "create",
"inputFields": [
"sc_showcase_pid", "sc_month", "sc_revenue",
"sc_cost", "sc_units", "sc_metric_remark"
],
"permissions": ["sc.showcase.manage"],
...
}inputFields is a whitelist, not documentation. Undeclared fields do not enter the persistence mapping. It does not validate the range of declared values: whether sc_month may be 13 is the responsibility of field constraints and command validation.
Add "sc_metric_target" to inputFields, re-import, and create the metric again:
curl -s -X POST "$BACKEND/api/meta/commands/execute/sc:create_monthly_metric" \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_showcase_pid":"<your recordPid>","sc_month":7,
"sc_revenue":1500,"sc_cost":700,"sc_units":30,
"sc_metric_target":777},"operationType":"CREATE"}'Use a new month to avoid the unique constraint on showcase record and month. Inspect the row identified by this response’s metric recordPid:
7 | 1500.00 | 777.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 — at two different gates
Reading the rule is not the same as feeling it. Make two colleagues and watch the platform tell one of them no.
# Sam: the tenant baseline. No business 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":"[email protected]",
"password":"Test2026x","roleCodes":["tenant_member"]},
"operationType":"CREATE"}'
# Rita: showcase administrator.
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":"[email protected]",
"password":"Test2026x","roleCodes":["sc_admin"]},
"operationType":"CREATE"}'Log in as each of them and run the same command with the same payload:
SAM=$(AURA_CORE_BASE_URL="$BACKEND" ADMIN_EMAIL=[email protected] \
ADMIN_PASSWORD=Test2026x python3 scripts/application/core-admin-session.py)
RITA=$(AURA_CORE_BASE_URL="$BACKEND" ADMIN_EMAIL=[email protected] \
ADMIN_PASSWORD=Test2026x python3 scripts/application/core-admin-session.py)
curl -s -X POST $BACKEND/api/meta/commands/execute/sc:create_monthly_metric \
-H "Authorization: Bearer $SAM" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_showcase_pid":"<your recordPid>","sc_month":6,
"sc_revenue":100,"sc_cost":50,"sc_units":5},
"operationType":"CREATE"}'curl -s -X POST "$BACKEND/api/meta/commands/execute/sc:create_monthly_metric" \
-H "Authorization: Bearer $RITA" -H 'Content-Type: application/json' \
-d '{"payload":{"sc_showcase_pid":"<your recordPid>","sc_month":6,
"sc_revenue":100,"sc_cost":50,"sc_units":5},
"operationType":"CREATE"}'Sam — tenant_member | 403 Access forbidden |
Rita — sc_admin | "code":"0", row inserted |
Same command. Same payload. The difference is the role.
You actually walked past two gates on the way in, and it is worth being able to name both:
- The endpoint gate. Everything arrives at one place,
/api/meta/commands/execute/..., and that endpoint itself requiresmeta.command.execute. Sam is refused here — the error'scontextnames the missing permission. If you made a member with only a business role and no execute grant, you would see the same refusal; deny-by-default means a role passes this gate only because someone granted it (the Showcase plugin grants it tosc_admininplugins/showcase/config/roles.json). - The command gate. Past the endpoint, the command's own declaration applies:
"permissions": ["sc.showcase.manage"]. This is the line written in the file you edited in step 3.
Neither is a UI-level check a determined caller can walk around. The endpoint is the only way to write. A person clicking Save, an automation, a BPM node, an AI agent calling a tool: all four arrive there, and all four meet the same two lines.
5. Read what you did
Every execution that gets past the gates leaves a record.
SELECT command_code, success, phase_reached, execution_time_ms
FROM ab_command_audit_log
ORDER BY created_at DESC
LIMIT 6; sc:create_monthly_metric | t | completion | 22
admin:create_member | t | completion | 109
sc:create_monthly_metric | t | completion | 35What the pipeline accepted, 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 endpoint gate 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 declared and bound a field; import added the database column. Page fields still require explicit declarations. |
| 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. |
| Writes pass two gates | The endpoint's meta.command.execute, then the command's own permission. Both are declarations, not buttons. |
| 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.