Track 07 · Data & governanceLesson 11 of 11
The operational model: objects, links and actions
Model the customer's operation once, so every agent reads and acts through it.
- The FDE path · Stage 1 of 6
- 35 min read
- 75 min hands-on
What you'll be able to do
01- Explain why a shared model of the customer's operation makes the second use case cheaper and the first agent safer than tools written straight against tables.
- Design object types, keys, properties and links from a source map, choosing the grain of each object and the system of record for each property.
- Define actions as the only way anything changes: typed parameters, submission criteria, role checks, approval tiers, idempotency keys and an audit record.
- Resolve one real-world thing across sources that disagree, keep provenance for every property, and send uncertain matches to a person instead of guessing.
- Generate an agent's tools from the actions, and answer "what did we know at 02:00?" separately from "what was true at 02:00?".
- Evolve the model when a source column is renamed, without changing a single consumer.
Before you start
- Finding where the data really lives: the source map, and the idea that authority is per attribute, not per system, are the inputs to this lesson.
- Is the data ready?: the curated views the model sits on, and where metric definitions live.
- Designing tools a model can use well: what makes a good tool. Here, tools are generated from actions.
- Approval gates and bounded autonomy: approval tiers, which actions borrow.
- Comfortable with Python dataclasses and SQL joins. No platform experience needed.
Starting here? On the core path you may meet this lesson before weeks 2 and 3. You need two ideas, each explained in a paragraph below: a tool is a function the model may ask your code to run, described by a JSON Schema; an approval gate is a person signing a consequential change before it goes through.
The short version
An agent that queries raw tables and writes through ad hoc API calls is a demo: every new use case repeats the joins, definitions drift between teams, and nobody can see who changed what. An operational model describes the customer's operation once, as objects (a shipment, an exception, a carrier), links between them, and actions, the only way anything changes. Each property remembers where its value came from and when you learned it. Each action checks who may run it, what must be true and who must approve, then writes through an adapter and leaves an audit record. The agent's tools are generated from the actions, so it can't do anything a person on the desk couldn't.
- Objects are the nouns the business already uses, at the grain operators work at, with one stable key each.
- Actions are the only write path. Typed parameters, submission criteria, permissions, approvals, idempotency and audit live in one place.
- Every property has provenance: the source, when it became true, and when you learned it. Losing values are kept.
- Uncertain matches go to a person. Entity resolution that guesses silently corrupts every object downstream.
- The model is thin and versioned. It sits on the customer's curated views and absorbs their schema changes in a mapping, so consumers never notice.
Mental model
04Think of the operations board in a port control room. It doesn't show tables. It shows vessels, berths, containers and cranes, and the lines between them: this container is on that vessel, which is at this berth. It also offers a short list of moves, each with a rule: you can reassign a berth only if it's free, and moving a hazardous container needs the duty officer's sign-off. Every move is logged with who made it. Nobody on the board writes to the terminal operating system directly; the board does that for them, the same way every time.
An operational model is that board, in software. The sources stay where they are; a mapping turns their rows into objects, resolves one carrier spelled five ways, and records where every value came from. Actions are the moves. The agent, the operator's screen and the weekly report share both.
flowchart LR
subgraph Sources
SAP["SAP S/4HANA extract in Snowflake"]
POR["Carrier portal scrape"]
DESK["Desk queue and spreadsheet"]
end
SAP --> MAP["Mapping v1<br/>and entity resolution"]
POR --> MAP
DESK --> MAP
MAP --> OBJ["Objects and links<br/>Shipment, Exception, Carrier, Lane, Document"]
OBJ --> READ["Agent, operator screen<br/>and reports read objects"]
READ -->|propose| ACT["Actions<br/>parameters, criteria, roles,<br/>approvals, idempotency"]
ACT -->|edits with provenance| OBJ
ACT --> AUD[("Audit log")]
ACT --> ADP["Adapters"]
ADP --> SAP2["SAP write"]
ADP --> MAIL["Email or WhatsApp"]
MAP -. uncertain match .-> REV["A person reviews"]The agent touches only the middle of the picture: it reads objects and proposes actions. It never holds a SAP credential, and it can't invent a write that the desk doesn't have.
Core concepts
05Why a model, not tables
The first agent at a customer usually starts as a set of SQL queries and API calls wrapped in tools: get_shipment, query_exceptions, update_sap_status. It works for the demo. Then three things happen.
- The second use case repeats the work. Cost reporting rewrites the same joins and gets different answers, because it filters cancelled bookings and you don't.
- Definitions drift. Halden's São Paulo desk counts an exception resolved when a resolution code is set; central ops, when SAP is updated and the consignee told. Two agents on two queries report two rates, and the sponsor trusts neither.
- Writes are invisible. Each tool calls an API with its own checks, or none. Who changed a customs code last Tuesday, and why, is spread across three log formats.
An operational model puts the shared meaning in one place: what a shipment is, what "resolved" means, and which changes are allowed. It's the semantic-layer idea from Is the data ready?, extended from numbers to things and from reading to changing.
Object types, identity and grain
An object type is a noun the business already uses, with a definition the people who do the work would agree with: Shipment, Exception, Carrier, Lane, Document. Start from the Finding where the data really lives and the shadowing notes, not from the table list. A table called ZSHIP_HDR is not an object; a shipment is.
Three decisions per type:
- Identity. One stable key that survives re-extracts and migrations. Prefer the system of record's business key (
SHIPMENT_IDfrom SAP,EXC-numbers from the desk) over warehouse surrogate keys. When no source has a good key, as with carriers spelled differently everywhere, mint one and keep a crosswalk to every source key. - Grain. Model at the grain operators act on. Halden's desk works one exception at a time, so Exception and Shipment are separate types, not one wide row. If a booking could carry three containers, Container would be its own type.
- Lifecycle. List the states and who moves an object between them (an exception is
OPEN, thenRESOLVED). States become a property; transitions become actions.
Keep the list short: Halden's first model has five types. Forty types in week two is usually the warehouse renamed.
Properties, provenance and survivorship
A property is a fact about an object: a shipment's ETA, its HS code, its invoice amount. In an enterprise, the same property often arrives from several sources that disagree. SAP has the planned ETA from booking; the carrier portal has a newer estimate; an email from the carrier says something else again.
Store every value, not just the winner, each with its provenance: the source, when it became true, and when the model learned it. A survivorship rule per property picks the current value, for example "ETA: portal over SAP, then the most recent": the per-attribute authority from Finding where the data really lives, made executable. Keeping the losers pays back the first time someone asks why the agent said 3 March when SAP said 1 March.
If the customer runs master data management, its survivorship rules are the starting point. Don't invent a second set.
Links and link objects
A link connects two objects: a shipment is carried by a carrier, on a lane; an exception is about a shipment; a document is for a shipment. Name links in the direction people speak, and record their cardinality (one shipment has one carrier; one shipment can have many exceptions), because cardinality decides what an agent can safely assume.
When a relationship has its own properties ("this container was on this vessel from Monday to Friday, loaded at Terminal B"), make it an object, such as Leg, linked to both ends. The test: if people ask about the relationship itself ("which legs were rolled?"), it's an object.
Entity resolution into objects
Objects only work if one real-world thing is one object. Halden's portal calls the same carrier "OSTRA LINES", "Ostra Lns" and "Ostra Lines S.A."; SAP calls it 0000400123. When questions need a graph builds entity resolution for a graph; the same technique applies here, with one rule added: an operational model can't afford a wrong merge, because actions will then change the wrong thing.
- Normalise, then match on keys you trust. Lower-case, strip punctuation and legal suffixes, expand known abbreviations, then compare against the master list. Anchor on master data whenever it exists.
- Report match classes, not a single yes/no: exact, normalised, probable, none. Exact and normalised matches go in. Probable matches go to a person, with the evidence, and the records they would affect wait until someone decides.
- Keep the crosswalk. Every alias, with its source and the date it was confirmed, becomes a property of the canonical object, so the next scrape resolves instantly and an auditor can see why two spellings are one carrier.
- Measure it. Precision matters more than recall in an operational model: a missed match leaves an event unlinked and visible in a review queue; a false match silently attaches one carrier's delays to another.
With several fields per record (name, address, tax ID), probabilistic record linkage such as the Fellegi–Sunter model, implemented in tools like Splink, weighs agreement across fields. The decision rules above still apply.
Actions: the only write path
An action type is a named change the business recognises: assign an exception, request a document, propose a resolution, release a customs hold. Nothing else writes. Not the agent, not the screen, not a script someone runs on a Friday. Each action type defines, in one place:
| Part | What it is | Halden example |
|---|---|---|
| Typed parameters | the inputs, with types and allowed values | exception_id: string, doc_type: one of packing_list, certificate_of_origin, commercial_invoice |
| Submission criteria | what must be true when the action is submitted | the exception is open; the document isn't already on file |
| Permissions | which roles may submit it | the agent and operators may request documents; only desk leads assign work |
| Approval tier | how many people, other than the submitter, must sign | none for an email; one for anything that changes SAP; two for releasing a customs hold |
| Effect | the edits to objects and links | Exception.status = RESOLVED, Exception.resolution = correct_hs_code |
| Adapter | how the change reaches the source system | the SAP adapter, with its own service credentials and change window |
| Idempotency key | a key that makes a repeat of the same request return the first result | the agent's retry after a timeout doesn't send a second email |
| Audit record | who, what, when, with which approvals, and the result | one line per attempt, including refusals |
An approval gate, if you're new to the term, is a rule that a person must sign before a consequential change goes through; Approval gates and bounded autonomy covers how to set the tiers. The approver must not be the submitter, and must hold an approver role: four eyes means two different people.
Idempotency (Retries, fallbacks and failing gracefully has the depth): the caller sends a unique key with each request, and the action returns the first applied result for any repeat instead of applying the change again. Stripe's API works this way and documents it publicly (as of October 2026, API reference). The example below stores only applied results, so a request refused on a precondition can be retried with the same key once the precondition is fixed.
Adapters are Agents inside CRM, ERP and ITSM: the SAP OData call, the email send. The action decides whether a change happens; the adapter decides how it reaches the system, with its change windows, retries and service identity. Keeping them apart means a SAP outage doesn't touch your business rules, and every action can run in dry-run mode during development. It also turns "the agent can update SAP" into a reviewable list of changes, roles and approvals, which is what a security review wants.
Derived properties and functions
Some properties are computed: hours left before cut-off, demurrage risk. Define each once, as a named function with an owner. "Resolved" is the classic case: for Halden, status = RESOLVED and the SAP write confirmed, read by both teams' dashboards. If teams really mean different things, model two named properties, not one ambiguous one. Model scores land here too: a classifier's predicted type is a property with its own provenance (model:exc-classifier v3), never a silent overwrite of the desk's label.
Valid time and record time
Every value has two times. Valid time is when the fact became true in the world: the carrier delayed the vessel at 23:30. Record time is when your model learned it: the scraper picked it up at 03:30. Martin Fowler's bitemporal history calls these actual and record time.
The difference decides two questions that come up in every incident review:
- What did the agent know when it acted? Filter by record time. At 02:00 the model still believed the old ETA, so the agent's 02:00 message to the consignee was right on what it knew.
- What was actually true at that moment? Filter by valid time, with everything you know now. The vessel was already late at 02:00.
Without both, you can't tell a wrong agent from a late scraper. It costs two timestamps per value.
Permissions at three levels
Authorization and multi-tenancy owns the policy models. The operational model applies them at three levels:
- Object level: the Jakarta desk sees Jakarta exceptions; a shipper sees only its own shipments.
- Property level: invoice amounts are visible to the desk and finance, not to the carrier-facing assistant.
- Action level: who may submit each action, and who may approve it.
Enforce these in the model, where every reader and writer passes through, not in each tool's prompt.
The model as the agent's interface
A tool, in agent terms, is a function your code exposes to the model, described by a name, a description and a JSON Schema for its parameters; the model asks for a call, and your code decides whether to run it. Generate the agent's tools from the action types: one tool per action, with the action's parameters as the schema and its description as the tool description. Reading tools come from the object types ("get exception with its shipment and documents").
For operational work this beats text-to-SQL over raw tables: the agent can't express a change the desk can't make, joins and permissions are applied before it sees anything, and a rule change updates the generated tool. Agents over tables: text-to-SQL and beyond still answers open analytical questions ("which lanes had the most mismatches last quarter?"); the two sit side by side.
Evolving the model
Sources change under you. Halden's SAP extract will one day rename INV_AMT to INVOICE_AMOUNT_DOC_CCY overnight. The model absorbs it in one place:
- Version the mapping, the table that says which source column feeds which property. A rename is a new mapping version; objects, actions, tools and screens don't change.
- Detect, don't guess. When an expected column is missing, the load fails loudly and names the column. A silent null in
invoice_amountis worse than a stopped pipeline. - Change the model additively. Add properties and types freely. To remove or redefine one, mark it deprecated, tell its consumers (the registry from Registries for models, prompts and agents knows who they are), and remove it after a stated date.
- Keep model versions in the audit trail, so an action from March can be read against the definitions that applied in March.
One model of the whole company is rarely feasible; domain-driven design's bounded contexts are the alternative. When finance needs "shipment" to mean something slightly different from the desk, give it its own context and an explicit mapping, not one bloated type that fits nobody.
Build or buy
There are four common ways to get an operational model, and you'll meet all of them:
| Option | What it is | Fits when | Watch for |
|---|---|---|---|
| A thin layer you build | Python or TypeScript types over the customer's curated views, with an action service in front of the source APIs (the worked example) | one or two use cases, a strong data team, no platform budget | you now own a service; keep it small and versioned |
| A semantic layer | shared metric and entity definitions on the warehouse, such as the dbt Semantic Layer | the pain is inconsistent numbers; the agent mostly reads | metric layers define reading, not writing; actions still need a home |
| A platform ontology | a product that models objects, links and actions natively, such as Palantir Foundry's Ontology | the customer has the platform, or many use cases across the business | the model lives in the platform; plan exports and an exit |
| The system of record's own model | the object model inside SAP, Salesforce or ServiceNow, with its own business rules | most changes happen in one system that already has the rules | cross-system objects (a shipment across SAP and the carrier portal) don't fit |
As of October 2026, Palantir documents its Ontology as semantic elements (objects, properties, links) and kinetic elements (actions, functions), with submission criteria deciding whether an action can be submitted (overview, submission criteria); dbt documents its Semantic Layer as central metric definitions powered by MetricFlow, on its Starter and Enterprise tiers (docs). This lesson's vocabulary maps onto both. Choose on what the customer already runs and who will own the model after you leave.
Worked example
06Scenario. Cut-off night at Santos for Halden Freight. Three sources describe 40 shipments: the nightly SAP extract (landed in Snowflake at 22:00), the carrier portal scrape, and the São Paulo desk's exception queue. You build a small model over them, resolve the carriers, define three actions, generate the agent's tools, and then run what goes wrong on a real night: a forbidden action, a retry, a failed precondition, an SAP change that needs an approver, and a late scrape that changes an ETA after the agent has written to the consignee.
Requirements. Python 3.11 or later, standard library only, no network. Save as halden_model.py and run python3 halden_model.py. The data is a small inline subset with the same column names and ID shapes as the Halden estate in _lab/estates/halden/, so the code runs on its own here and against the estate in the exercise.
"""Halden's operational model: objects, links and actions over three sources that disagree.
Standard library only; runs offline. Column names and ID shapes match the Halden estate in
_lab/estates/halden (this is a small inline subset, so the file runs on its own).
"""
from __future__ import annotations
import difflib
import json
import random
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
UTC = timezone.utc
NIGHT = datetime(2026, 2, 12, 0, 0, tzinfo=UTC) # the night of a Thursday cut-off at Santos
def at(hhmm: str, day: int = 0) -> datetime:
h, m = map(int, hhmm.split(":"))
return NIGHT + timedelta(days=day, hours=h, minutes=m)
# --- 1. Three sources, as they arrive ------------------------------------------------
rng = random.Random(2026)
SAP_VENDORS = {"0000400123": "Ostra Lines", "0000400131": "Kestrel Line", "0000400147": "Orca Shipping"}
OWNER = {"0000400123": "OSLU", "0000400131": "KSLU", "0000400147": "ORCU"}
PORTAL_NAMES = {"0000400123": ["OSTRA LINES", "Ostra Lns", "Ostra Lines S.A."],
"0000400131": ["KESTREL LINE", "Kestrel Line Ltd"],
"0000400147": ["Orca Shpg", "ORCA SHIPPING", "Orcas Shipping Co"]}
HS = ["0901.11", "6204.62", "0806.10", "4011.10"]
sap_shipments, portal_events, desk_queue, documents = [], [], [], []
for i in range(40):
vendor = rng.choice(sorted(SAP_VENDORS))
sid = f"40{i + 1:08d}"
eta = (NIGHT + timedelta(days=17 + rng.randint(0, 2))).date().isoformat()
sap_shipments.append({"SHIPMENT_ID": sid, "CARRIER_VENDOR_ID": vendor, "ORIGIN_PORT": "BRSSZ",
"DEST_PORT": "NLRTM", "CONTAINER_NO": f"{OWNER[vendor]}{rng.randint(10**6, 10**7 - 1)}",
"HS_CODE": rng.choice(HS), "ETA": eta, "INV_AMT": f"{rng.uniform(2e4, 9e4):.2f}",
"INV_CCY": "USD"})
portal_events.append({"carrier_name": rng.choice(PORTAL_NAMES[vendor]), "shipment": sid,
"event_code": "LOADED", "eta": eta, "event_time": at("23:10", -1), "scraped_at": at("01:40")})
if i % 3 == 0:
documents.append({"doc_id": f"DOC-0{i + 1:06d}", "shipment_id": sid, "doc_type": "packing_list"})
# Shipment 17's vessel was reported late at 23:30, but the scraper only picked that up at 03:30.
v17 = sap_shipments[16]["CARRIER_VENDOR_ID"]
portal_events[16]["carrier_name"] = PORTAL_NAMES[v17][0]
portal_events.append({"carrier_name": PORTAL_NAMES[v17][1], "shipment": "4000000017", "event_code": "ETA_CHANGED",
"eta": "2026-03-05", "event_time": at("23:30", -1), "scraped_at": at("03:30")})
for n, sid in enumerate(["4000000017", "4000000004", "4000000009", "4000000023", "4000000031"]):
desk_queue.append({"EXC_ID": f"EXC-{10482 + n}", "SHIPMENT_ID": sid, "DESK": "São Paulo",
"TYPE": ("WRONG_HS_CODE", "MISSING_DOCS", "INVOICE_MISMATCH")[n % 3], "STATUS": "OPEN"})
# --- 2. Entity resolution: one carrier, many spellings ----------------------------
ABBREV = {"lns": "lines", "ln": "line", "shpg": "shipping"}
SUFFIXES = {"sa", "ltd", "co", "gmbh", "pte"}
def norm(name: str) -> str:
words = re.sub(r"[^a-z0-9 ]", "", name.lower().replace("s.a.", "sa")).split()
return " ".join(ABBREV.get(w, w) for w in words if w not in SUFFIXES)
def resolve(name: str, master: dict[str, str]) -> tuple[str | None, str, float]:
"""(vendor id, match class, score). Probable matches go to a person, never straight in."""
for vid, canon in master.items():
if name == canon:
return vid, "exact", 1.0
score, vid = max((difflib.SequenceMatcher(None, norm(name), norm(c)).ratio(), v) for v, c in master.items())
if score == 1.0:
return vid, "normalised", 1.0
return (vid, "probable", round(score, 2)) if score >= 0.9 else (None, "none", round(score, 2))
# --- 3. Objects, links, and a value history per property ----------------------------
@dataclass
class Value:
value: object
source: str # SAP | portal | desk | action:<name>
valid_from: datetime # when it became true in the world
recorded: datetime # when the model learned it
@dataclass
class Obj:
type: str
key: str
props: dict[str, list[Value]] = field(default_factory=dict)
links: dict[str, set[str]] = field(default_factory=dict)
# Survivorship: which source wins a property, in order; the losers are kept, not deleted.
SURVIVOR = {"eta": ("portal", "SAP"), "status": ("action", "desk")}
DEFAULT_ORDER = ("action", "SAP", "portal", "desk")
# The mapping is versioned: a renamed column changes this, not every consumer.
MAPPING = {"version": 1, "Shipment": {"container": "CONTAINER_NO", "hs_code": "HS_CODE", "eta": "ETA",
"invoice_amount": "INV_AMT", "invoice_currency": "INV_CCY"}}
class Model:
def __init__(self) -> None:
self.objects: dict[str, Obj] = {}
self.audit: list[dict] = []
self.ledger: dict[str, dict] = {} # idempotency key -> first applied result
self.review: list[tuple[str, str, str]] = [] # (name, why, shipment) for a person to decide
def obj(self, type_: str, key: str) -> Obj:
return self.objects.setdefault(key, Obj(type_, key))
def put(self, key: str, prop: str, value, source: str, valid_from: datetime, recorded: datetime) -> None:
self.objects[key].props.setdefault(prop, []).append(Value(value, source, valid_from, recorded))
def link(self, a: str, name: str, b: str) -> None:
self.objects[a].links.setdefault(name, set()).add(b)
def get(self, key: str, prop: str, as_of: datetime | None = None, valid_at: datetime | None = None) -> Value | None:
"""The surviving value. as_of = what we knew then; valid_at = what was true then."""
vals = [v for v in self.objects[key].props.get(prop, [])
if (as_of is None or v.recorded <= as_of) and (valid_at is None or v.valid_from <= valid_at)]
order = SURVIVOR.get(prop, DEFAULT_ORDER)
rank = lambda v: (order.index(v.source.split(":")[0]), -v.valid_from.timestamp()) # noqa: E731
return min(vals, key=rank) if vals else None
def val(self, key: str, prop: str):
v = self.get(key, prop)
return v.value if v else None
def linked(self, key: str, name: str) -> list[Obj]:
return [self.objects[k] for k in sorted(self.objects[key].links.get(name, ()))]
def build() -> Model:
m, extract = Model(), at("22:00", -1) # the nightly SAP extract landed at 22:00
for vid, name in SAP_VENDORS.items():
m.obj("Carrier", vid)
m.put(vid, "name", name, "SAP", extract, extract)
m.obj("Lane", "BRSSZ-NLRTM")
m.put("BRSSZ-NLRTM", "cutoff", "Thursday 15:00 local", "SAP", extract, extract)
for row in sap_shipments:
s = m.obj("Shipment", row["SHIPMENT_ID"])
for prop, column in MAPPING["Shipment"].items():
m.put(s.key, prop, row[column], "SAP", extract, extract)
m.link(s.key, "carried_by", row["CARRIER_VENDOR_ID"])
m.link(s.key, "on_lane", "BRSSZ-NLRTM")
m.link("BRSSZ-NLRTM", "shipments", s.key)
for ev in portal_events:
vid, how, score = resolve(ev["carrier_name"], SAP_VENDORS)
expected = next(iter(m.objects[ev["shipment"]].links["carried_by"]))
if how == "probable" or vid != expected: # a person decides before the event counts
why = f"{how} match ({score})" if how == "probable" else f"resolves to {vid}, SAP says {expected}"
m.review.append((ev["carrier_name"], why, ev["shipment"]))
continue
m.put(vid, "alias", ev["carrier_name"], "portal", ev["scraped_at"], ev["scraped_at"])
m.put(ev["shipment"], "eta", ev["eta"], "portal", ev["event_time"], ev["scraped_at"])
for row in desk_queue:
e = m.obj("Exception", row["EXC_ID"])
m.put(e.key, "type", row["TYPE"], "desk", at("01:05"), at("01:05"))
m.put(e.key, "status", row["STATUS"], "desk", at("01:05"), at("01:05"))
m.put(e.key, "desk", row["DESK"], "desk", at("01:05"), at("01:05"))
m.link(e.key, "about", row["SHIPMENT_ID"])
m.link(row["SHIPMENT_ID"], "exceptions", e.key)
for d in documents:
m.obj("Document", d["doc_id"])
m.put(d["doc_id"], "doc_type", d["doc_type"], "SAP", extract, extract)
m.link(d["doc_id"], "for", d["shipment_id"])
m.link(d["shipment_id"], "documents", d["doc_id"])
return m
# --- 4. Actions: the only way anything changes -------------------------------------
USERS = {"rafaela.souza": {"desk_lead", "approver"}, "camila.rocha": {"operator"},
"joao.ferreira": {"operator", "approver"}, "exception-agent": {"agent"}}
OPERATORS = {"OP-SP-01": {"invoice", "customs", "reefer"}, "OP-SP-02": {"invoice", "documents"},
"OP-SP-03": {"customs", "reefer"}}
SKILL = {"WRONG_HS_CODE": "customs", "MISSING_DOCS": "documents", "INVOICE_MISMATCH": "invoice"}
FITS = {"WRONG_HS_CODE": {"correct_hs_code"}, "MISSING_DOCS": {"request_document"},
"INVOICE_MISMATCH": {"request_revised_invoice", "accept_sap_amount"}}
WRITES_SAP = {"correct_hs_code", "accept_sap_amount"} # these change the system of record
JSON_TYPE = {str: "string", int: "integer"}
@dataclass(frozen=True)
class Param:
name: str
kind: type
choices: tuple = ()
description: str = ""
def exc_type(m: Model, a: dict):
return m.val(a["exception_id"], "type")
def is_open(m: Model, a: dict) -> bool:
return a["exception_id"] in m.objects and m.val(a["exception_id"], "status") == "OPEN"
@dataclass(frozen=True)
class ActionType:
name: str
description: str
params: tuple[Param, ...]
roles: frozenset[str]
checks: tuple # submission criteria: (what must be true, test)
effect: object # (model, args) -> [(object key, property, new value)]
approvals: object = lambda a: 0 # how many approvers this call needs
adapter: str = "" # the system written to, if any
def validate(self, args: dict) -> list[str]:
out = [f"unknown parameter {k}" for k in args if k not in {p.name for p in self.params}]
for p in self.params:
if not isinstance(args.get(p.name), p.kind):
out.append(f"{p.name} must be {JSON_TYPE[p.kind]}")
elif p.choices and args[p.name] not in p.choices:
out.append(f"{p.name} must be one of {list(p.choices)}")
return out
def tool(self) -> dict:
"""The agent's tool is generated from the action, so the two can't drift apart."""
props = {p.name: {"type": JSON_TYPE[p.kind], **({"description": p.description} if p.description else {}),
**({"enum": list(p.choices)} if p.choices else {})} for p in self.params}
return {"name": self.name, "description": self.description,
"input_schema": {"type": "object", "properties": props,
"required": [p.name for p in self.params], "additionalProperties": False}}
EXC = Param("exception_id", str, description="EXC- id of an open exception")
ACTIONS = {a.name: a for a in (
ActionType("assign_exception", "Assign an open exception to an operator with the right skill.",
(EXC, Param("operator_id", str, tuple(OPERATORS))), frozenset({"desk_lead"}),
(("the exception is open", is_open),
("the operator has the skill this type needs",
lambda m, a: is_open(m, a) and SKILL[exc_type(m, a)] in OPERATORS[a["operator_id"]])),
lambda m, a: [(a["exception_id"], "assigned_to", a["operator_id"])]),
ActionType("request_document", "Ask the shipper for a missing document by a deadline.",
(EXC, Param("doc_type", str, ("packing_list", "certificate_of_origin", "commercial_invoice")),
Param("due_hours", int, description="1 to 72")),
frozenset({"agent", "operator", "desk_lead"}),
(("the exception is open", is_open),
("the deadline is 1 to 72 hours away", lambda m, a: 1 <= a["due_hours"] <= 72),
("the document is not already on file", lambda m, a: is_open(m, a) and a["doc_type"] not in {
m.val(d.key, "doc_type") for s in m.linked(a["exception_id"], "about")
for d in m.linked(s.key, "documents")})),
lambda m, a: [(a["exception_id"], "waiting_for", a["doc_type"])], adapter="email"),
ActionType("propose_resolution", "Resolve an open exception. Changes to SAP need one approver.",
(EXC, Param("resolution", str, tuple(sorted(set().union(*FITS.values())))),
Param("note", str, description="why, in one sentence")),
frozenset({"agent", "operator", "desk_lead"}),
(("the exception is open", is_open),
("the resolution fits the exception type",
lambda m, a: is_open(m, a) and a["resolution"] in FITS[exc_type(m, a)])),
lambda m, a: [(a["exception_id"], "status", "RESOLVED"), (a["exception_id"], "resolution", a["resolution"])],
approvals=lambda a: 1 if a["resolution"] in WRITES_SAP else 0, adapter="SAP"),
)}
def sap_dry_run(m: Model, args: dict) -> dict:
"""What the SAP adapter would send. A real one calls the customer's API with its own credentials."""
s = m.linked(args["exception_id"], "about")[0]
return {"system": "SAP S/4HANA (dry run)", "shipment": s.key, "change": args["resolution"],
"hs_code_before": m.val(s.key, "hs_code")}
def submit(m: Model, name: str, args: dict, actor: str, key: str, now: datetime,
approvers: tuple[str, ...] = ()) -> dict:
if key in m.ledger: # the same request again (a retry, a double click): same answer, no new change
return {**m.ledger[key], "replayed": True}
a = ACTIONS[name]
reasons = a.validate(args)
if not reasons and not USERS.get(actor, set()) & a.roles:
reasons = [f"{actor} may not run {name}"]
if not reasons:
reasons = [f"failed: {what}" for what, test in a.checks if not test(m, args)]
signed = sorted({u for u in approvers if u != actor and "approver" in USERS.get(u, set())})
if reasons:
result = {"status": "refused", "reasons": reasons}
elif len(signed) < a.approvals(args):
result = {"status": "pending_approval", "needs": a.approvals(args), "has": len(signed)}
else:
edits = a.effect(m, args)
for k, prop, value in edits:
m.put(k, prop, value, f"action:{name}", now, now)
sent = sap_dry_run(m, args) if a.adapter == "SAP" and args["resolution"] in WRITES_SAP else None
result = {"status": "applied", "edits": edits, **({"sent": sent} if sent else {})}
m.ledger[key] = result
m.audit.append({"at": now.isoformat(), "action": name, "actor": actor, "approvers": signed,
"args": args, "key": key, "status": result["status"]})
return result
# --- 5. Run it ---------------------------------------------------------------------
def show(label: str, result: dict) -> None:
print(f" {label:<44} {json.dumps(result, ensure_ascii=False, default=str)}")
def main() -> None:
print("1. Carrier resolution (portal name -> SAP vendor)")
for name in sorted({ev["carrier_name"] for ev in portal_events}):
vid, how, score = resolve(name, SAP_VENDORS)
print(f" {name:<20} {how:<11} {score:<5} {vid or '-'} {SAP_VENDORS.get(vid or '', '')}")
m = build()
kinds: dict[str, int] = {}
for o in m.objects.values():
kinds[o.type] = kinds.get(o.type, 0) + 1
print(f"\n2. Model: {kinds}; mapping v{MAPPING['version']}; for review: {len(m.review)}")
for name, why in sorted({(n, w) for n, w, _ in m.review}):
held = sorted(s for n, w, s in m.review if (n, w) == (name, why))
print(f" review: {name!r} is a {why}; {len(held)} events held, e.g. {held[0]}")
s = "4000000017"
print(f"\n3. Provenance of {s}.eta (every value kept, in the order learned)")
for v in sorted(m.objects[s].props["eta"], key=lambda v: v.recorded):
print(f" {v.value} from {v.source:<6} valid {v.valid_from:%d %H:%M} recorded {v.recorded:%d %H:%M}")
print(f" now: {m.get(s, 'eta').value}")
print(f" as we knew it at 02:00: {m.get(s, 'eta', as_of=at('02:00')).value}"
" <- what the agent saw when it wrote to the consignee")
print(f" true at 02:00, known now: {m.get(s, 'eta', valid_at=at('02:00')).value}")
print("\n4. Tools generated from actions")
for a in ACTIONS.values():
t = a.tool()
print(f" {t['name']}({', '.join(t['input_schema']['required'])})")
print(" " + json.dumps(ACTIONS["request_document"].tool()["input_schema"]["properties"]["doc_type"]))
print("\n5. Actions")
assign = {"exception_id": "EXC-10482", "operator_id": "OP-SP-03"}
show("agent tries to assign", submit(m, "assign_exception", assign, "exception-agent", "k1", at("02:05")))
show("desk lead assigns", submit(m, "assign_exception", assign, "rafaela.souza", "k2", at("02:06")))
show("same request again (retry)", submit(m, "assign_exception", assign, "rafaela.souza", "k2", at("02:06")))
show("wrong skill", submit(m, "assign_exception", {"exception_id": "EXC-10483", "operator_id": "OP-SP-03"},
"rafaela.souza", "k3", at("02:07")))
docs = {"exception_id": "EXC-10483", "doc_type": "packing_list", "due_hours": 24}
show("agent asks for a packing list on file", submit(m, "request_document", docs, "exception-agent", "k4", at("02:08")))
fix = {"exception_id": "EXC-10482", "resolution": "correct_hs_code", "note": "declared code differs from the invoice"}
show("agent proposes an SAP change", submit(m, "propose_resolution", fix, "exception-agent", "k5", at("02:10")))
show("...with one approver", submit(m, "propose_resolution", fix, "exception-agent", "k5", at("02:31"),
approvers=("joao.ferreira",)))
print(f"\n6. Audit log: {len(m.audit)} attempts, {sum(e['status'] == 'applied' for e in m.audit)} applied")
for e in m.audit:
print(f" {e['at'][11:16]} {e['actor']:<16} {e['action']:<19} {e['status']:<17} approvers={e['approvers']}")
assert m.val("EXC-10482", "status") == "RESOLVED" and m.val("EXC-10482", "assigned_to") == "OP-SP-03"
assert len([e for e in m.audit if e["key"] == "k2"]) == 1, "a replay must not write twice"
if __name__ == "__main__":
main()What the run shows. An excerpt of the output, with what each part demonstrates:
1. Carrier resolution (portal name -> SAP vendor)
Kestrel Line Ltd normalised 1.0 0000400131 Kestrel Line
Orcas Shipping Co probable 0.96 0000400147 Orca Shipping
Ostra Lns normalised 1.0 0000400123 Ostra Lines
2. Model: {'Carrier': 3, 'Lane': 1, 'Shipment': 40, 'Exception': 5, 'Document': 14}; mapping v1; for review: 4
review: 'Orcas Shipping Co' is a probable match (0.96); 4 events held, e.g. 4000000002
3. Provenance of 4000000017.eta (every value kept, in the order learned)
2026-03-03 from SAP valid 11 22:00 recorded 11 22:00
2026-03-03 from portal valid 11 23:10 recorded 12 01:40
2026-03-05 from portal valid 11 23:30 recorded 12 03:30
now: 2026-03-05
as we knew it at 02:00: 2026-03-03 <- what the agent saw when it wrote to the consignee
true at 02:00, known now: 2026-03-05
5. Actions
agent tries to assign {"status": "refused", "reasons": ["exception-agent may not run assign_exception"]}
same request again (retry) {"status": "applied", ..., "replayed": true}
wrong skill {"status": "refused", "reasons": ["failed: the operator has the skill this type needs"]}
agent proposes an SAP change {"status": "pending_approval", "needs": 1, "has": 0}
...with one approver {"status": "applied", ..., "sent": {"system": "SAP S/4HANA (dry run)", ...}}
6. Audit log: 6 attempts, 2 applied- Resolution sorts names into classes. Seven spellings resolve after normalisation. "Orcas Shipping Co" scores 0.96 and still waits for a person, with its four events: in a real estate that queue holds new carriers, typos and subsidiaries, and only a person can tell them apart.
- Provenance keeps every value. Shipment 4000000017 has three ETA values from two sources. The survivorship rule picks 5 March now, and the model can also say it believed 3 March at 02:00 while the vessel was in fact already late. The consignee's complaint is settled with evidence.
- Actions refuse in readable ways. Only desk leads assign work; an assignment that ignores skills names the criterion it failed; the packing list is already on file, so no email goes out.
- The retry is harmless. The same idempotency key returns the first result marked
replayed, and the final assertion checks the audit log holds one applied entry for it. - An SAP change waits for a second person. The proposal stays pending until an approver who isn't the submitter signs; every attempt, including refusals, is in the audit log the CISO asked for.
- The tools came from the actions. Add a fourth action and the agent gets a fourth tool; nobody edits a prompt.
Loading the interactive version…
Left out on purpose: persistence, concurrency (two operators on one exception need optimistic locking), real adapters and real identity from the customer's directory.
In the field
07Halden Freight, week two of the exception-desk engagement. The first agent drafts resolutions for invoice mismatches on the Santos and Jakarta lanes. The SI's account lead wants speed: "Give the agent read access to Snowflake and a service account on SAP. It can figure out the joins." Two things surface that week. The São Paulo desk reports 91% of exceptions resolved within 24 hours and Priya in central ops reports 74%; both are right on their own definitions (a resolution code set, versus SAP updated and the consignee told). And the CISO asks a question nobody can answer quickly: "Show me every change made to SAP by anything other than a person last week, and who approved it."
What the FDE does. She proposes a thin operational model over the curated views Halden's data team already maintains: five object types, nine links and four actions to start. "Resolved" is written down once, as a derived property both teams sign (status = RESOLVED and the SAP write confirmed), and the desk's own measure survives under its own name, desk_closed_at. The agent loses its SAP service account: it proposes, the action checks roles, criteria and approvals, and only the action service holds SAP credentials. The CISO's question becomes a query over one audit table.
In the review.
Data-platform owner: "You're building a second warehouse. We already have curated views and a semantic layer for the finance metrics. I don't want another copy of shipment data that drifts from ours."
FDE: "Agreed, a second warehouse would be a mistake, so this isn't one. The model stores no shipment data of its own: it reads your curated views through a versioned mapping, and writes only the audit log and the changes actions push into SAP. Your semantic layer keeps the finance metrics. What the model adds is what views can't do: who may change an exception, under which rules, with whose approval. I'd like your team to co-own the mapping, so a schema change on your side is one reviewed pull request, not a broken agent."
The objection behind the objection is ownership, so the answer names owners for after the FDE leaves: the data team owns the mapping, the desk lead owns the action rules, and security owns the approver roles.
Decision guide
08Should this use case get an operational model, or is a query enough?
| Signal | Query or text-to-SQL is enough | Build the operational model |
|---|---|---|
| The agent only reads, for analysis | yes | not yet |
| The agent changes anything in a system of record | yes, actions first | |
| Two teams or two use cases need the same things | yes | |
| A definition ("resolved", "late", "active customer") is disputed | yes, as a named derived property | |
| Auditors or the CISO will ask who changed what | yes, the action log answers it | |
| Data comes from three or more sources that disagree | yes, with provenance and survivorship | |
| A one-off report for one person | yes | no |
Questions to answer for each object type before you build it.
- Would the people doing the work agree with your one-sentence definition?
- What is its key, who issues it, and does it survive a re-extract and a migration?
- For each property: which source wins, and where do the losers go?
- Which states does it move through, and which action moves it?
Questions to answer for each action type.
- Who may submit it, and who, other than the submitter, must approve it?
- What must be true first, stated as checks you can test?
- Which system does it change, through which adapter, identity and change window?
- What does a retry with the same key return, and what does the audit record keep?
Misconceptions
09Myth
“An ontology is a data catalogue with nicer names”
A catalogue describes datasets; an operational model describes the things the business acts on and the changes it allows. Without actions, nothing stops an agent's unchecked write.
Myth
“The agent is smart enough to work out the joins from the schema”
On a good day. It will also apply its own idea of "resolved", miss the cancelled-booking filter, and do it differently tomorrow. Joins and definitions that matter are tested once in the model, not re-derived per prompt.
Myth
“Keep only the latest value; history is for the warehouse”
The latest value can't say what the agent knew when it acted. Without record time, a late scrape looks like an agent error.
Myth
“Fuzzy matching at 0.9 is good enough to merge”
A false merge attaches one company's delays and actions to another. Merge exact and normalised matches; send probable ones to a person, and measure precision on a labelled sample before changing the threshold.
Myth
“Approval means the agent asks 'are you sure?'”
A chat confirmation isn't an approval. An approval is a different person with an approver role signing a specific action with specific parameters, on the record.
When it breaks
10| Symptom | Likely root cause | How you detect it | Fix / prevention |
|---|---|---|---|
| Two dashboards show different resolution rates | two definitions of "resolved" in two queries | reconcile the numbers on a sample of exceptions | one derived property, signed by both teams; rename the other metric |
| An agent action changed the wrong carrier's shipments | a probable match was merged automatically | precision drops on the labelled sample; operators report wrong documents | probable matches go to review; alias confirmations recorded with who and when |
| The agent "invented" an ETA in a consignee email | a late scrape changed the value after the agent read it | the as-of query shows the agent's value was correct at record time | keep valid and record time; show freshness in the agent's context and the operator's screen |
| Duplicate emails or double SAP updates after a timeout | retries without idempotency keys | the audit log shows two applied entries for one intent | idempotency key per request; store and return the first applied result |
| Every consumer broke when SAP renamed a column | consumers read source columns directly | load fails, or worse, fills nulls | versioned mapping; loud failure on missing columns; consumers read properties only |
| The model slowly becomes a second warehouse | properties added for every report request | model size and load time grow; reports read from it | keep it operational; send analytical questions to the warehouse and the semantic layer |
| Approvals become a rubber stamp | the same two people approve everything in seconds | median time to approve; approver concentration | tier approvals by risk; sample approved actions in a weekly review |
Exercise
11Task. Extend halden_model.py with a customs hold and absorb Halden's SAP schema change. Time: 60–90 minutes.
Starter. These additions go into the worked example; the names they use (Param, ActionType, MAPPING, submit) are defined there.
# Add to halden_model.py. A sketch: fill in the checks, the effect and the new data.
MAPPING_V2 = {"version": 2, "Shipment": {**MAPPING["Shipment"],
"invoice_amount": "INVOICE_AMOUNT_DOC_CCY",
"invoice_currency": "DOC_CCY",
"eta": "ETA_PLANNED"}}
RELEASE_HOLD = ActionType(
"release_hold", "Release a customs hold once the broker confirms clearance. Needs two approvers.",
(Param("hold_id", str, description="HOLD- id of an active hold"),
Param("clearance_ref", str, description="the broker's clearance reference")),
frozenset({"desk_lead"}),
(), # TODO: the hold is active; the clearance reference is not empty
lambda m, a: [], # TODO: hold status -> RELEASED, with the clearance reference
approvals=lambda a: 2, adapter="SAP")Steps.
- Add a
CustomsHoldobject type: keyHOLD-plus digits, propertiesstatus(ACTIVE or RELEASED),reasonandplaced_at, and a linkonto a Shipment (andholdsback). Seed two active holds on shipments that have exceptions. - Finish
release_hold: submission criteria (the hold is active; the clearance reference is not empty), the effect, and two approvers who are neither the submitter nor each other. Register it inACTIONS.submitcurrently assumes every SAP action has aresolution; generalise the adapter call so each action type supplies its own dry-run payload. - Add a second source extract for the same 40 shipments using the v2 column names (
INVOICE_AMOUNT_DOC_CCY,DOC_CCY,ETA_PLANNED), and makebuild()choose the mapping from the extract's columns. If a mapped column is missing, fail with a message that names it. - Run the original demonstration unchanged against the v2 extract.
Done looks like.
release_holdwith one approver returnspending_approval. Listing the submitter plus one other approver still counts as one. Two different approvers, neither the submitter, apply it, and the dry-run adapter reports the change.- Submitting the same release twice with one idempotency key returns the first result marked
replayed, and the audit log has one applied entry for it. - The as-of query for shipment 4000000017 still returns 3 March at 02:00 and 5 March now.
- The tool list has exactly one more tool than before,
release_hold(hold_id, clearance_ref), and no other tool changed. - Against the v2 extract, every line of the demonstration prints the same as against v1, and a v2 extract with
DOC_CCYremoved fails with an error namingDOC_CCY.
Stretch. Generate the Halden estate (python3 _lab/estates/halden/generate.py --out ./halden-estate), run your resolve() over every distinct carrier_name in dirty/carrier_events.csv, and report precision and recall per match class against truth/entities.json. Then decide, with numbers, whether to accept "probable" matches for any carrier automatically.
Check yourself
12The cost-reporting team wants to reuse your agent's query_exceptions tool for their dashboard. Why is that a warning sign, and what do you offer instead?
SAP says a shipment's ETA is 1 March; the carrier portal says 5 March. Which do you show the operator, and what else do you keep?
Why does the worked example refuse to merge "Orcas Shipping Co" even though it scores 0.96?
A consignee complains that the agent told them on time at 02:00 when the vessel was already late. How do you find out whether the agent was wrong?
The agent retries request_document after a timeout. What must be true so the shipper doesn't get two emails?
A desk lead proposes releasing a customs hold and lists herself and a colleague as approvers. Should it apply?
Connections
13- Builds on: Finding where the data really lives, Is the data ready?, Designing tools a model can use well, Approval gates and bounded autonomy
- Used by: Agents inside CRM, ERP and ITSM (the adapters actions call), The audit pack (the action log as evidence), Feasibility and data access, fast
- Related: Agents over tables: text-to-SQL and beyond, Lineage: proving where an answer came from, When questions need a graph, Authorization and multi-tenancy, Retries, fallbacks and failing gracefully
Further reading
14- Palantir Foundry: Ontology overview: the vendor's description of semantic elements (objects, properties, links) and kinetic elements (actions, functions); a useful vocabulary even if the customer doesn't run Foundry.
- Palantir Foundry: action types and submission criteria: actions as single transactions with parameters and the conditions that decide whether they can be submitted.
- dbt Semantic Layer: central metric definitions with MetricFlow; read it to see where a semantic layer stops and actions begin.
- Martin Fowler: Bounded Context: why one unified model of a large business rarely works, and how to map between contexts.
- Martin Fowler: Bitemporal History: actual (valid) and record time, with a worked payroll example that maps directly onto the 02:00 question.
- Splink: the Fellegi–Sunter model: the statistics behind probabilistic record linkage (match probability, m and u, match weights), from the maintainers of a widely used open-source tool.
- Stripe API: idempotent requests: a clear public description of how a production API stores and replays results per idempotency key.