Documentation

Worked example — landing a model's output

One real run, end to end - re-pointing references, the upsert key that was not a key, and the seat check that found a defect the row count could not.

⚙️ This is a record of one real run, not a specification. The numbers are what happened on one app. Read the mechanisms as durable and the figures as an account.

04 — Data in and out describes the shape: model where the analyst already works, land the output by webhook, govern it with seats. This is that story with the numbers left in, including the parts that went wrong.

The situation

A design workspace held 262 activity records. The deployed client app held 0. Same entities, same pages — and one of those pages carried an Activities table that had been empty since the day it was built.

Nothing was broken. Seeds run on a virgin install only: a redeploy never re-seeds, duplicates or overwrites end-user records. Configuration ships on every deploy; data does not. That is the correct behaviour, and its consequence is that data added to a workspace after the first deploy stays in the workspace.

⚙️ The same is true of people. A person the deploy has not seen before is created, so a seat added in Build gets a working login. A person who is already in the app is left alone — their name, their role and their password belong to the running app, and a deploy does not touch them. Change those in the app’s own Users screen.

First question: would those rows even be correct there?

Not “how do I copy them” — “do they mean anything over there?”

The activities reference deal and rep by id. Measured against the deployed app:

deal references resolving in the app :   0 / 187
rep  references resolving in the app :   0 / 5

Every one would have dangled. Copying verbatim would have put 262 rows in front of the client each pointing at nothing — a populated table that is wrong, which is worse than the empty one it replaced. An empty state says “no data”. A broken join says nothing at all, and looks fine doing it.

Why re-pointing was safe

The two sides turned out to hold the same data, generated independently:

rep  names identical : 12 / 12       ids shared : 0
deal names identical : 190 / 190     ids shared : 0

So the question became whether a natural key could identify a deal on both sides. name alone could not — 345 rows across 190 names. But:

app deals    : 343 rows, 343 distinct (name + closeDate + dealValue), 0 duplicates
studio deals : 345 rows, all 345 matched an app key

That makes the mapping an identity lookup, not a guess — and the distinction is the whole decision. Reps mapped on fullName. Result: 262 / 262 activities mappable.

⚠️ One lossy edge, measured rather than waved away: two studio deals shared a key and collapse onto a single app deal. Zero of the 262 activities touched them, so it moved nothing. Had it been thirty, that would have been a decision to take deliberately, not a footnote.

The upsert key that was not a key

An inbound endpoint upserts on a key you choose, so a job that re-sends its whole output updates rows instead of doubling them. The obvious key here is (deal, rep, occurredAt) — who did what, when.

(deal, rep, occurredAt)                          → 188 / 262 distinct
(deal, rep, occurredAt, note, durationMinutes)   → 262 / 262 distinct

It is not unique. Using it would have silently collapsed 74 rows on the first re-run — losing a quarter of the data, quietly, inside the mechanism whose entire purpose is preventing exactly that.

⚙️ Count your key against the source before you send anything. If the number of distinct tuples is not the number of rows, it is not a key. This takes one query and it is the difference between an idempotent pipe and a lossy one.

The delivery

Endpoint created with a field mapping and that upsert key, request HMAC-signed, sent in batches:

batch 1: 202  recordsLoaded 100
batch 2: 202  recordsLoaded 100
batch 3: 202  recordsLoaded  62
                            ───
                            262

Two mistakes on the way, both easy to repeat:

⚠️ The receiver URL was constructed instead of read. /hooks/<tenant>/<path> looked right and produced three 404s. The create response returns a receiverUrl and the real prefix is /webhooks/inbound/. The endpoint tells you where it lives — ask it rather than inferring it from the shape you expect.

⚠️ A reference needs a business key, not an id. A model — or any system that pushes — knows its own identifiers, never the ones your app generated. Map the reference field to the key the sender actually has (“the Deal whose Reference is this”) rather than expecting a UUID. A key that matches nothing is refused, and the refusal now names which key and which entity.

⚠️ The signature went in the wrong header — 401 signature_invalid: missing_header. Each scheme names its own header; hmac_sha256 uses x-signature.

Choosing a scheme

schemeheadersignspick it when
hmac_sha256x-signaturethe raw bodya generic system that can HMAC
githubx-hub-signature-256the raw bodyGitHub
stripex-stripe-signaturetimestamp.bodyStripe
ttpx-ttp-signaturetimestamp.bodyanother TTP app is calling in
none——a source that genuinely cannot sign

★ ttp is the one to pick when the caller is one of your own deployed apps. TTP’s outbound webhooks sign that way, and the inbound scheme matches them — so an app calling another app was refused, while an outside system signing the documented way went through. The platform could not verify its own webhooks.

⚙️ ttp refuses a replay outright, which the others cannot. It requires a fresh x-ttp-timestamp (five-minute window), and a missing or stale one is a rejection rather than a shrug. hmac_sha256 has to accept a captured-and-resent body because an arbitrary caller may have no clock to agree on; when the sender is TTP, that excuse does not apply.

⚠️ Both ends must be given the same secret, by hand. There is no exchange between a sending subscription and a receiving endpoint — read the endpoint’s secret, then create the subscription with that exact value. A mismatch reads as a broken integration rather than a copy-paste error.

Then the check that makes it a pipe rather than a one-off: the whole load was run a second time. Still 262, not 524.

Verify by effect, on a real seat

activities in the app        : 262
dangling deal/rep references :   0

Then the check that counts: sign in as an ordinary user, not as an admin. An admin never consults the permission matrix — the access decision short-circuits before entity permissions are read at all — so an admin’s green screen proves nothing about what a client’s staff will see. Signed in on an executive seat, the Activities table rendered rows.

⚠️ And that seat check found a defect the row count could not. The table showed data, and two of its columns showed raw ids, because reference columns were rendering the id instead of the referenced record’s name. “The widget shows data” and “the widget is readable” are two different claims, and only one of them was true. The load was correct; the screen was not usable until that was fixed separately.

Last: look at what you are about to send

Two of the 262 were test rows left in the workspace by an earlier experiment — notes reading MODEL-OUT scenario A briefing. They rode along with everything else into the client’s app and had to be removed from both sides afterwards.

⚙️ A design workspace accumulates scratch data the way any working file does. The rows you are about to put in front of a client deserve one look before they go.

What to take from it

Before copying rows between instancesask whether their references resolve there, not whether the rows are valid
Before choosing an upsert keycount distinct tuples in the source; the obvious key is often not one
Before trusting the endpointread the receiver URL it returns; do not build it
Before calling it doneload the page on a non-admin seat — an admin bypasses the matrix entirely
Before shipping the datacheck the source for your own scratch rows