Skip to main content
Version: 13 - TBD

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

  1. Turn a set of requirements into an object template — choosing attribute types, defaults, and deciding what belongs in the template versus in a flow.
  2. Attach hook flows (on_create / on_update) for validation, derived fields and side effects — and avoid the classic write-back loop.
  3. Make additive (non-breaking) changes with confidence.
  4. Grow a flat model into a relational one (multiple templates joined by reference attributes) when requirements demand it.
  5. Migrate existing data to the new shape safely — expand, backfill, verify, then tighten — with no orphaned or half-migrated records.
  6. 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.

KindResources
Object templatesara-deployment, ara-component, ara-environment, ara-package, ara-package-component
Hook flowsara-deployment-before-create, ara-deployment-on-create, ara-deployment-on-update
Consumer flowara-run-deployment
Migration flowsara-seed-v1-deployments, ara-migrate-deployment-refs, ara-verify-deployment-migration, ara-contract-drop-legacy-fields
Integration testintegrationtest-refimpl-release-automation
About the ara- prefix

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 value keyed by the template's attribute names.
  • Attribute datatypes used here: STRING, ALLOWED_VALUES (a fixed enum set), BOOLEAN, INTEGER, MARKDOWN, DATETIME, and OBJECT_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)

AttributeDatatypeNotes / why
componentSTRINGFree text for now (e.g. api-backend). It becomes a reference in Stage 4 — starting as a string is deliberate and safe.
versionSTRINGe.g. 1.4.2. Validated by a hook, not by the datatype.
environmentALLOWED_VALUES = dev / staging / prodA small, closed, known set → an enum. Enums give the reader a dropdown and cheap validity.
statusALLOWED_VALUES = pending / running / succeeded / failedThe lifecycle. Defaults to pending.
deployed_atDATETIMESet by a hook, not by the user (see Stage 2).

Modelling decisions worth calling out:

  • Enum vs string. Use ALLOWED_VALUES when the set is small, closed and known (environment, status); use STRING when values are open or high-cardinality (component, version). We revisit component precisely 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. status defaults to pending, 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')).

The write-back-loop footgun

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:

  1. silent_update (declarative). silent_update is a per-attribute flag on the object template, not a save() argument. An attribute marked silent_update=True (here, deployed_at) can be written without re-triggering the hooks.
  2. Flip-detection (imperative). Only write when the value actually needs to change, and in on_update react to a transition (compare value against old_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):

  1. Schema validation is the only hard pre-create gate. Required attributes, ALLOWED_VALUES enums and datatypes are enforced before any hook runs. A violation returns an error and no CO is created — e.g. omitting the required environment, or passing environment='production' (not in the enum), is rejected outright. Put invariants that must always hold, for every caller including agents, into the schema.
  2. before_create is 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-create prefills environment=dev and status=pending.
  3. on_create runs after the row exists. Calling this.error() in on_create marks the object CREATE_FAILED — it does not prevent the object from existing. Use on_create for derived fields and side effects, not as a gate.
  4. 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 — see ara-run-deployment in Stage 6, which rejects a malformed version before any CO is minted.
Validation, in one line

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_bySTRING, default manual.
  • notesMARKDOWN, 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:

TemplateKey attributesRole
ara-componentname, team, repository_urlmaster record for a component
ara-environmentname, rank (INTEGER), is_production (BOOLEAN)master record for an environment
ara-packagename, version, statusgroups components into a release
ara-package-componentpackage (ref), component (ref), component_versionjoin table for the many-to-many

Then, on ara-deployment, the breaking part:

  • component (STRING) is superseded by component_ref (OBJECT_TEMPLATE_REFERENCEara-component).
  • environment (ALLOWED_VALUES) is superseded by environment_ref (OBJECT_TEMPLATE_REFERENCEara-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.

Model relationships as references, never as prose ids

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)

  1. Create the new templates (ara-component, ara-environment, ara-package, ara-package-component) and any master data.
  2. On ara-deployment, add component_ref and environment_ref alongside the existing component and environment. 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:

  1. Derives the reference set. Scans every ara-deployment; for each distinct component string, it matches or creates an ara-component CO (matching first, so an existing master record's fields are never clobbered). For environment, it maps each enum value to the corresponding ara-environment CO via an explicit {dev, staging, prod} map the migration owns.
  2. Backfills the references on each deployment (component_ref, environment_ref), read-modify-writing the full value dict.
  3. Resolves template ids by name, never hardcoded — a bundle install mints fresh ids, so a hardcoded id would break on the reader's workspace.
  4. Offers a dry_run input, default true. 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.
  5. Is idempotent. A deployment that already has both references set is skipped; re-running never duplicates master records.
A dry run must predict the real run exactly

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-deployment has a non-empty component_ref and environment_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:

  1. Move every writer to the new fields first (hooks, consumer flows — Stage 6).
  2. Then drop the now-unused component and environment attributes.

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.

Dropping an attribute purges its data

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-environment reference and match-or-creates the ara-component reference.

It also:

  • Hard-validates the version in the flow (N.N.N, else this.error() and no CO is created) — the Stage 2 lesson that a must-always-block rule belongs in the creating flow, not in an on_create hook.
  • 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 the on_update hook fires once on the terminal transition.
  • Treats a failed deployment 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-runmigrate real (dry-run fidelity: predicted == actual)verify must PASSmigrate 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 flow-authoring detail worth knowing

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

  1. Model the requirement you have — start flat; add structure when a real requirement forces it, not speculatively.
  2. Template = shape, flow = behaviour — keep declarative structure and imperative logic separate.
  3. Additive-first, default-on-add — a new optional, defaulted attribute is a zero-risk change; prefer it.
  4. Never break a hook contract silently — write back with a silent_update attribute and flip-detection; react to transitions, not states.
  5. 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.
  6. Migrate-then-tighten (expand → migrate → verify → contract) — never change a field's meaning in place.
  7. 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.
  8. Verify with a flow, not by eye — assert post-conditions (no dangling references, no duplicates, full coverage) before removing the old shape.
  9. Model relationships as reference attributes — never an id pasted into a text field (references get rewired by tooling; prose ids dangle).
  10. Protect it with an integration test — so the model stays correct as the product evolves.

Install and run it

  1. Install the Refimpl_release_automation bundle.
  2. Run ara-seed-v1-deployments to create flat v1-shape deployment records.
  3. Run ara-migrate-deployment-refs with dry_run=true, read the plan, then run it with dry_run=false.
  4. Run ara-verify-deployment-migration — it should end successfully.
  5. Run ara-run-deployment to author a new deployment through the consumer flow.
  6. 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.
  7. Run integrationtest-refimpl-release-automation any time to confirm the whole arc still holds together.

For the underlying primitives, see Object Templates and Custom Objects and the Flow API reference.