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.

What you'll be able to do

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

Before you start

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

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

Mental model

04

Think 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.

Diagram
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

Core concepts

05

Why 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_ID from 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, then RESOLVED). 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.

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:

PartWhat it isHalden example
Typed parametersthe inputs, with types and allowed valuesexception_id: string, doc_type: one of packing_list, certificate_of_origin, commercial_invoice
Submission criteriawhat must be true when the action is submittedthe exception is open; the document isn't already on file
Permissionswhich roles may submit itthe agent and operators may request documents; only desk leads assign work
Approval tierhow many people, other than the submitter, must signnone for an email; one for anything that changes SAP; two for releasing a customs hold
Effectthe edits to objects and linksException.status = RESOLVED, Exception.resolution = correct_hs_code
Adapterhow the change reaches the source systemthe SAP adapter, with its own service credentials and change window
Idempotency keya key that makes a repeat of the same request return the first resultthe agent's retry after a timeout doesn't send a second email
Audit recordwho, what, when, with which approvals, and the resultone 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_amount is 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:

OptionWhat it isFits whenWatch for
A thin layer you buildPython 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 budgetyou now own a service; keep it small and versioned
A semantic layershared metric and entity definitions on the warehouse, such as the dbt Semantic Layerthe pain is inconsistent numbers; the agent mostly readsmetric layers define reading, not writing; actions still need a home
A platform ontologya product that models objects, links and actions natively, such as Palantir Foundry's Ontologythe customer has the platform, or many use cases across the businessthe model lives in the platform; plan exports and an exit
The system of record's own modelthe object model inside SAP, Salesforce or ServiceNow, with its own business rulesmost changes happen in one system that already has the rulescross-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

Worked example

06

Scenario. 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.

python
"""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:

text
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.
InteractiveWalk the model, then try to break itThe worked example's model on cut-off night: 5 exceptions, 40 shipments, 3 carriers, the Santos lane and 14 documents, joined by links. Shipment 4000000017's ETA keeps all three values with their valid and record times, and the three actions refuse or wait in the same words as the run above.

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

In the field

07
Field note

Halden 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

Decision guide

08

Should this use case get an operational model, or is a query enough?

SignalQuery or text-to-SQL is enoughBuild the operational model
The agent only reads, for analysisyesnot yet
The agent changes anything in a system of recordyes, actions first
Two teams or two use cases need the same thingsyes
A definition ("resolved", "late", "active customer") is disputedyes, as a named derived property
Auditors or the CISO will ask who changed whatyes, the action log answers it
Data comes from three or more sources that disagreeyes, with provenance and survivorship
A one-off report for one personyesno

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

Misconceptions

09

Myth

“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

When it breaks

10
SymptomLikely root causeHow you detect itFix / prevention
Two dashboards show different resolution ratestwo definitions of "resolved" in two queriesreconcile the numbers on a sample of exceptionsone derived property, signed by both teams; rename the other metric
An agent action changed the wrong carrier's shipmentsa probable match was merged automaticallyprecision drops on the labelled sample; operators report wrong documentsprobable matches go to review; alias confirmations recorded with who and when
The agent "invented" an ETA in a consignee emaila late scrape changed the value after the agent read itthe as-of query shows the agent's value was correct at record timekeep valid and record time; show freshness in the agent's context and the operator's screen
Duplicate emails or double SAP updates after a timeoutretries without idempotency keysthe audit log shows two applied entries for one intentidempotency key per request; store and return the first applied result
Every consumer broke when SAP renamed a columnconsumers read source columns directlyload fails, or worse, fills nullsversioned mapping; loud failure on missing columns; consumers read properties only
The model slowly becomes a second warehouseproperties added for every report requestmodel size and load time grow; reports read from itkeep it operational; send analytical questions to the warehouse and the semantic layer
Approvals become a rubber stampthe same two people approve everything in secondsmedian time to approve; approver concentrationtier approvals by risk; sample approved actions in a weekly review
Exercise

Exercise

11
Hands-on~75 min

Task. 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.

python
# 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.

  1. Add a CustomsHold object type: key HOLD- plus digits, properties status (ACTIVE or RELEASED), reason and placed_at, and a link on to a Shipment (and holds back). Seed two active holds on shipments that have exceptions.
  2. 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 in ACTIONS. submit currently assumes every SAP action has a resolution; generalise the adapter call so each action type supplies its own dry-run payload.
  3. Add a second source extract for the same 40 shipments using the v2 column names (INVOICE_AMOUNT_DOC_CCY, DOC_CCY, ETA_PLANNED), and make build() choose the mapping from the extract's columns. If a mapped column is missing, fail with a message that names it.
  4. Run the original demonstration unchanged against the v2 extract.

Done looks like.

  • release_hold with one approver returns pending_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_CCY removed fails with an error naming DOC_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

Check yourself

12
1

The 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?

2

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?

3

Why does the worked example refuse to merge "Orcas Shipping Co" even though it scores 0.96?

4

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?

5

The agent retries request_document after a timeout. What must be true so the shipper doesn't get two emails?

6

A desk lead proposes releasing a customs hold and lists herself and a colleague as approvers. Should it apply?

Connections

Connections

13
Further reading

Further reading

14