Reference implementation: evolving a data model safely
This is a guided, runnable walkthrough of how to build a data model out of object templates and custom objects, put it to work with hook flows, and then evolve it safely as requirements change — without breaking existing hooks, consumer flows, or data.
The example carries one domain — an Application Release Automation (ARA) model of components, environments, packages and deployments — through six stages of realistic evolution, from a single flat record type to a relational model with a data migration. Every stage is backed by real, installable Cloudomation resources so you can install the bundle and step through it yourself.
The through-line is a single discipline:
additive-first → default-on-add → migrate-then-tighten → verify-with-a-flow → never break a hook contract silently.
What you will learn
- Turn a set of requirements into an object template — choosing attribute types, defaults, and deciding what belongs in the template versus in a flow.
- Attach hook flows (
on_create/on_update) for validation, derived fields and side effects — and avoid the classic write-back loop. - Make additive (non-breaking) changes with confidence.
- Grow a flat model into a relational one (multiple templates joined by reference attributes) when requirements demand it.
- Migrate existing data to the new shape safely — expand, backfill, verify, then tighten — with no orphaned or half-migrated records.
- Keep hook flows and consumer flows in sync with the schema and guard against regressions with an integration-test flow.
The runnable bundle
Everything below is implemented in the bundle Refimpl_release_automation.
Install it, then open each resource as you read the matching stage.
| Kind | Resources |
|---|---|
| Object templates | ara-deployment, ara-component, ara-environment, ara-package, ara-package-component |
| Hook flows | ara-deployment-before-create, ara-deployment-on-create, ara-deployment-on-update |
| Consumer flow | ara-run-deployment |
| Migration flows | ara-seed-v1-deployments, ara-migrate-deployment-refs, ara-verify-deployment-migration, ara-contract-drop-legacy-fields |
| Integration test | integrationtest-refimpl-release-automation |
All resources are namespaced with ara- only so the example can be installed
next to your own resources without name collisions (generic names like
deployment or component are likely to clash). In your own model, drop the
prefix and use the plain domain names.
The bundle ships ara-deployment in its expand-phase shape (it still carries
the retired v1 fields alongside the new relational references) together with a
seed flow that recreates the v1 starting point, so you can replay the whole
migration as often as you like.
Concepts in one minute
- An object template (OT) defines a type of record: its named attributes (each with a datatype and optional default) and optional hook flows that run automatically when a matching custom object is created or updated.
- A custom object (CO) is one instance of a template. Its data lives in a
JSON
valuekeyed by the template's attribute names. - Attribute datatypes used here:
STRING,ALLOWED_VALUES(a fixed enum set),BOOLEAN,INTEGER,MARKDOWN,DATETIME, andOBJECT_TEMPLATE_REFERENCE(a typed pointer to a custom object of a named template — the relational building block). - Hook flows are ordinary flows the template runs for you. A hook receives
the changed object's id and its new (and, on update, previous)
value. This is where validation, derived fields and side effects live.
See the Object Templates and Custom Objects
page for the underlying concepts, and the
Flow API reference for CustomObject and
ObjectTemplate.
Stage 1 — Initial requirements → the first model
Requirement (v1). "Record each deployment: which component, which version, to which environment, and whether it succeeded."
That is a single flat record type. Resist the urge to model components and environments as their own types yet — model the requirement you have, not the one you imagine. A flat model is the cheapest thing that satisfies v1 and the easiest to grow later.
Object template ara-deployment (v1)
| Attribute | Datatype | Notes / why |
|---|---|---|
component | STRING | Free text for now (e.g. api-backend). It becomes a reference in Stage 4 — starting as a string is deliberate and safe. |
version | STRING | e.g. 1.4.2. Validated by a hook, not by the datatype. |
environment | ALLOWED_VALUES = dev / staging / prod | A small, closed, known set → an enum. Enums give the reader a dropdown and cheap validity. |
status | ALLOWED_VALUES = pending / running / succeeded / failed | The lifecycle. Defaults to pending. |
deployed_at | DATETIME | Set by a hook, not by the user (see Stage 2). |
Modelling decisions worth calling out:
- Enum vs string. Use
ALLOWED_VALUESwhen the set is small, closed and known (environment,status); useSTRINGwhen values are open or high-cardinality (component,version). We revisitcomponentprecisely because it turns out not to be open-ended. - Template vs flow. The shape (attributes, allowed values, defaults) is
declarative and lives on the template. Behaviour (validate the version,
stamp
deployed_at, react to a status change) is imperative and lives in a hook flow. Keep the two separated. - Defaults.
statusdefaults topending, so a freshly-created deployment is always in a valid state even if the creator omits it.
Console path (human automation expert)
Resources → Object templates → New → name ara-deployment → add the five
attributes above (set status default to pending, and the environment /
status allowed values) → Save. Create a couple of deployment custom objects to
see the auto-generated form.
Flow API / MCP equivalent (agents and scripted setup)
The same template, created in one atomic call by attaching the attributes as
children:
# create the OT and its attributes in one transaction
create_record(record_type="OBJECT_TEMPLATE", payload={
"name": "ara-deployment",
"children": {"object_template_attribute": [
{"name": "component", "datatype": "STRING"},
{"name": "version", "datatype": "STRING"},
{"name": "environment", "datatype": "ALLOWED_VALUES",
"allowed_values": [
{"const": "dev", "label": "Development"},
{"const": "staging", "label": "Staging"},
{"const": "prod", "label": "Production"}]},
{"name": "status", "datatype": "ALLOWED_VALUES", "default_value": "pending",
"allowed_values": [
{"const": "pending", "label": "Pending"},
{"const": "running", "label": "Running"},
{"const": "succeeded", "label": "Succeeded"},
{"const": "failed", "label": "Failed"}]},
{"name": "deployed_at", "datatype": "DATETIME", "silent_update": True},
]},
})
The silent_update flag on deployed_at is explained in Stage 2 — it is the
declarative half of the write-back-loop guard.
Stage 2 — First hook flows
Requirement (v1, continued). "Stamp when a deployment is created; when it reaches a terminal state, record the outcome; reject malformed versions."
The hook contract
An on_create / on_update hook flow receives, in its inputs:
custom_object_id— the CO that changed,value— the new attribute values (inputs.get('value')),old_value— the previous values, on update (inputs.get('old_value')).
The identity that made the change is available as the hook execution's
created_by (this.get('created_by')).
If a hook writes back to the same CO with a normal save, that save triggers
on_update again → which writes again → an infinite loop. Guard against it two
ways, combined:
silent_update(declarative).silent_updateis a per-attribute flag on the object template, not asave()argument. An attribute markedsilent_update=True(here,deployed_at) can be written without re-triggering the hooks.- Flip-detection (imperative). Only write when the value actually needs to
change, and in
on_updatereact to a transition (comparevalueagainstold_value) rather than to a state, so the side effect fires exactly once.
ara-deployment-on-create
Stamp deployed_at (a derived field), only if it is unset:
def handler(system, this, inputs):
co_id = inputs['custom_object_id']
value = inputs.get('value') or {}
# flip-detection: only write if the derived field is actually missing.
if not value.get('deployed_at'):
# deployed_at is a silent_update attribute on the template, so this
# write does NOT re-trigger the hooks. save() MERGES: passing a single
# key leaves every other attribute untouched.
system.custom_object(co_id).save(value={'deployed_at': this.now_iso()})
ara-deployment-on-update
React once, on the transition into a terminal state:
def handler(system, this, inputs):
value = inputs.get('value') or {}
old = inputs.get('old_value') or {}
if value.get('status') != old.get('status') and value.get('status') in ('succeeded', 'failed'):
this.log(message=f"deployment reached terminal status: {value['status']}")
# side effects go here: notify, write an audit CO, etc.
Where validation really belongs
It is tempting to think a hook can reject a bad create. It cannot — and knowing exactly what each layer does is the most valuable lesson of this stage. The create pipeline is the same for every caller (Console, MCP, REST):
- Schema validation is the only hard pre-create gate. Required attributes,
ALLOWED_VALUESenums and datatypes are enforced before any hook runs. A violation returns an error and no CO is created — e.g. omitting the requiredenvironment, or passingenvironment='production'(not in the enum), is rejected outright. Put invariants that must always hold, for every caller including agents, into the schema. before_createis a Console convenience only. It runs solely to prefill the Console "new object" form with defaults; it does not run on programmatic (MCP / REST) creation and cannot reject a create. Use it for convenient starting values for the human, nothing more. In the bundle,ara-deployment-before-createprefillsenvironment=devandstatus=pending.on_createruns after the row exists. Callingthis.error()inon_createmarks the objectCREATE_FAILED— it does not prevent the object from existing. Useon_createfor derived fields and side effects, not as a gate.- Cross-record rules that must hard-block belong in the creating flow. A rule
the schema cannot express (e.g. "version must be
N.N.N", or "no two active deployments to prod") and that must prevent creation should be validated in the consumer flow that creates the CO — seeara-run-deploymentin Stage 6, which rejects a malformed version before any CO is minted.
Schema = the hard gate for every caller · before_create = Console prefill ·
on_create = derive / side-effect (can only mark CREATE_FAILED) · consumer
flow = hard-block cross-record rules.
Stage 3 — First modifications (additive, non-breaking)
New requirement. "Also capture who triggered a deployment, and free-text notes."
This is the easy, safe kind of change — purely additive. Add two attributes to
ara-deployment:
triggered_by—STRING, defaultmanual.notes—MARKDOWN, no default (genuinely optional).
Why it is safe, spelled out:
- Existing deployment COs keep working: a new attribute with a default reads as its default on old records; an optional one reads as empty. No existing record becomes invalid.
- No hook contract changes: the new fields are optional and unread by existing logic.
- No migration needed.
The rule this establishes — additive-first, default-on-add: when a new
requirement can be met by adding an optional, defaulted attribute, do exactly
that. It is a zero-risk change. Save the heavy machinery (Stages 4–5) for when the
shape must change, not merely grow. Contrast this with Stage 1's required
environment, whose omission is rejected — adding a required attribute with no
default to a populated template is not additive-safe.
Stage 4 — New requirements → a relational model
New requirements (v2).
- "Components are real things we own — each has an owning team and a source repository — and we deploy the same component many times."
- "Environments have their own properties — a rank in the promotion order and whether they are production."
- "A release ships several components together as a package."
A free-text component string and an environment enum cannot carry this. We move
to a relational model by introducing four more templates:
| Template | Key attributes | Role |
|---|---|---|
ara-component | name, team, repository_url | master record for a component |
ara-environment | name, rank (INTEGER), is_production (BOOLEAN) | master record for an environment |
ara-package | name, version, status | groups components into a release |
ara-package-component | package (ref), component (ref), component_version | join table for the many-to-many |
Then, on ara-deployment, the breaking part:
component(STRING) is superseded bycomponent_ref(OBJECT_TEMPLATE_REFERENCE→ara-component).environment(ALLOWED_VALUES) is superseded byenvironment_ref(OBJECT_TEMPLATE_REFERENCE→ara-environment).
Why a join template? A package holds many components and a component appears
in many packages — a many-to-many. When that relationship carries its own data
(here, the pinned component_version), model it with a dedicated join template
(ara-package-component) rather than a list field. That is when a join template is
the right tool.
Why this is a breaking change. Existing ara-deployment COs hold a component
name string and an environment enum value — not a reference to an
ara-component / ara-environment CO (which do not exist yet). Reads and hooks
that expect the old shape would break if you simply repurposed the fields. This is
exactly the change that requires a migration (Stage 5), not a bare edit.
Reference attributes are foreign-key-modelled, so Cloudomation's tooling (bundle release/install, export/import) rewires them by identity automatically when ids change across workspaces. A record id pasted into a text attribute is not rewired and will dangle after an install. Always use a reference datatype for a relationship — never a UUID stored in a string.
Stage 5 — Migrating existing data safely
This is the heart of the guide: getting from flat v1 records to the relational v2 model without breaking anything or losing data. The safe pattern is expand → migrate → verify → contract (migrate-then-tighten), never a single destructive rename.
5.1 Expand (additive, non-breaking)
- Create the new templates (
ara-component,ara-environment,ara-package,ara-package-component) and any master data. - On
ara-deployment, addcomponent_refandenvironment_refalongside the existingcomponentandenvironment. Do not delete the old fields yet.
Now every deployment CO is still valid (old fields intact) and simply has empty new reference fields — a dual-shape transition state where both the old and new readers can work.
5.2 Migrate — a flow with a dry run (ara-migrate-deployment-refs)
The migration flow:
- Derives the reference set. Scans every
ara-deployment; for each distinctcomponentstring, it matches or creates anara-componentCO (matching first, so an existing master record's fields are never clobbered). Forenvironment, it maps each enum value to the correspondingara-environmentCO via an explicit{dev, staging, prod}map the migration owns. - Backfills the references on each deployment (
component_ref,environment_ref), read-modify-writing the fullvaluedict. - Resolves template ids by name, never hardcoded — a bundle install mints fresh ids, so a hardcoded id would break on the reader's workspace.
- Offers a
dry_runinput, defaulttrue. On a dry run it computes and reports every create/update it would make, and writes nothing. Run it dry first, read the plan, then run it for real. This is the single most important migration habit. - Is idempotent. A deployment that already has both references set is skipped; re-running never duplicates master records.
If two source rows share a new component name, a naive dry run reports two
creates, but the real run creates it once (the second matches the first). A dry
run that over-predicts is worse than none — it teaches the reader to distrust it.
The fix: track names you have already planned to create within the same dry run
(a planned set), so the dry-run count equals the real-run count.
5.3 Verify — a flow, not eyeballing (ara-verify-deployment-migration)
A verification flow asserts the post-conditions and fails loudly otherwise:
- every
ara-deploymenthas a non-emptycomponent_refandenvironment_ref; - every reference resolves to a live CO of the correct template (catching unset, dangling, or wrong-template references).
Run it before the contract step. Seeing it fail before migration (the gate has teeth) and pass after is the payoff of doing verification as code.
5.4 Contract — tighten, only after verify is green (ara-contract-drop-legacy-fields)
Once verification passes on all records:
- Move every writer to the new fields first (hooks, consumer flows — Stage 6).
- Then drop the now-unused
componentandenvironmentattributes.
The contract flow gates itself on the verify flow (it will not drop a field until
migration is verified complete) and defaults dry_run=true, because dropping a
schema attribute is irreversible.
Removing an attribute from a template purges that key from the value of every
existing CO — there is no residual, recoverable data. This is why the ordering
matters: stop writing a field, migrate its data elsewhere, verify, then drop it.
Dropping a field that a flow still writes will make those writes fail.
The distilled rule: never mutate a field's meaning in place. Add the new shape, migrate into it, verify, move the readers, then remove the old shape. Each step leaves the system in a valid, working state.
Stage 6 — Updating flows without regressions
The consumer flow ara-run-deployment
The flow a user runs to perform a deployment. Its most important teaching point:
The user-facing input contract is unchanged across the migration (component name + environment enum). Only the internals evolve — the flow now resolves the environment to its
ara-environmentreference and match-or-creates theara-componentreference.
It also:
- Hard-validates the version in the flow (
N.N.N, elsethis.error()and no CO is created) — the Stage 2 lesson that a must-always-block rule belongs in the creating flow, not in anon_createhook. - Writes the deployment in the expand-phase uniform shape (both the retiring string/enum fields and the new references), so every record stays uniform until the contract step.
- Drives the lifecycle
pending → running → terminal, so theon_updatehook fires once on the terminal transition. - Treats a
faileddeployment as a legitimate business outcome (the record captures it; the flow itself succeeds).
Guard against regressions with an integration test
integrationtest-refimpl-release-automation drives the whole lifecycle as real
child executions and asserts the observable contract at each step:
seed (v1) → verify must FAIL (gate has teeth) → migrate dry-run →
migrate real (dry-run fidelity: predicted == actual) → verify must PASS →
migrate again (idempotent: 0 migrated, 0 created) → run-deployment consumer (references set, component matched not duplicated) → verify still passes.
This is what stops the reference implementation (and the discipline it teaches) from rotting as the product evolves — the guide's own advice, applied to itself.
A child execution's status reads as ENDED_SUCCESS / ENDED_ERROR (the
ENDED_ prefix), not success / error. When asserting on a child's outcome,
compare against the full status (or match the ENDED_ prefix), or your assertion
will silently never match.
Safety principles — the one-page checklist
- Model the requirement you have — start flat; add structure when a real requirement forces it, not speculatively.
- Template = shape, flow = behaviour — keep declarative structure and imperative logic separate.
- Additive-first, default-on-add — a new optional, defaulted attribute is a zero-risk change; prefer it.
- Never break a hook contract silently — write back with a
silent_updateattribute and flip-detection; react to transitions, not states. - Validate at the right layer — schema for hard invariants (every caller), the creating flow for cross-record hard blocks; hooks derive and react, they do not gate.
- Migrate-then-tighten (expand → migrate → verify → contract) — never change a field's meaning in place.
- Dry-run every migration, and make it idempotent — see the plan before you apply it, and make a run you can repeat safely; a dry run must predict the real run exactly.
- Verify with a flow, not by eye — assert post-conditions (no dangling references, no duplicates, full coverage) before removing the old shape.
- Model relationships as reference attributes — never an id pasted into a text field (references get rewired by tooling; prose ids dangle).
- Protect it with an integration test — so the model stays correct as the product evolves.
Install and run it
- Install the
Refimpl_release_automationbundle. - Run
ara-seed-v1-deploymentsto create flat v1-shape deployment records. - Run
ara-migrate-deployment-refswithdry_run=true, read the plan, then run it withdry_run=false. - Run
ara-verify-deployment-migration— it should end successfully. - Run
ara-run-deploymentto author a new deployment through the consumer flow. - When you are ready to tighten, run
ara-contract-drop-legacy-fields(dry_run=false) — but note the bundle ships it dry-run-safe so the seed → migrate → verify demo stays replayable. - Run
integrationtest-refimpl-release-automationany time to confirm the whole arc still holds together.
For the underlying primitives, see Object Templates and Custom Objects and the Flow API reference.