agentleFS
engine9-aiengine9-ai/skills/e9-model/SKILL.md

e9-model

Skill1 starsChanged 48 days agoApache-2.0View on GitHub

Explains engine9 models: a model reads a person's timeline of entries and decides which source code deserves credit for that person (first touch, CRM origin, last acquisition), writing {prefix}_* tables (model_first_touch_person, …) that answer lifetime value (LTV) and acquisition ROI questions. Models connect people and their transactions to PEOPLE-level origin; attribution connects a transaction to a MESSAGE and is never a model peer. Covers the timeline effective-date cascade (timeline.ts / effective_date, default_timestamp, extract_timestamp, source_code_date, acquisition_date, SOURCE_CODE_OVERRIDE) that every model sorts by. Use when working with ModelWorker, model_*_person, model_*_transaction, lifetime value, LTV, acquisition ROI, first touch, CRM origin, reading/running a model, or a segment definition for people in or out of a current model by source code (segmentDefinitionClauses, personSourceCode / transactionSourceCode). Legacy model clauses are legacy.md and are used only when the user explicitly asks for legacy.

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 4 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every person in engine9 has a timeline: a list of things that happened to them or that they did, in time order.

    line 46
  2. Distinctive:

    Do not call last click "the last-click model", list it among the models, or put it in a model comparison as if it were a peer.

    line 115
  3. Distinctive:

    Every rule above is a statement about time — *earliest* entry, *most recent* acquisition.

    line 163
  4. Distinctive:

    Read it in this order:

    line 316

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

engine9 models

An engine9 model reads a person's timeline and assigns person-level source-code credit for questions such as lifetime value and acquisition ROI. Use this skill to understand shipped models, inspect model output, diagnose a person's result, run a model, or select people in or out of a current model by source code. It assumes no prior engine9 knowledge. Legacy model clauses are legacy.md, and only when the user explicitly asked for legacy.

Quick reference

NeedStart here
Understand model inputs and decisionsConcepts
Distinguish models from attributionRules
Inspect output tables or query resultsFile format and Examples
Investigate one person's creditTroubleshooting
Run a modelWorkflow
Select people in or out of a current model by source codeModel segment definition clauses
Legacy model membership (only if the user asked)legacy.md

Concepts

The timeline a model reads

Every person in engine9 has a timeline: a list of things that happened to them or that they did, in time order. One row is one entry — one fact about one person at one moment.

effective date (ts)entrysource code on the entry
2019-03-02signed a petitionWEB_PET_2019_climate
2019-03-05email sendEM_WEL_20190305
2020-01-14email clickEM_FR_20200114_appeal
2020-01-14transaction, $25EM_FR_20200114_appeal
2023-06-01transaction, $50MAIL_JUN2023

A source code is the campaign/effort label carried on an entry ("the June 2023 mail piece", "the climate petition"). Details: e9-timeline and e9-source-code.

The timeline is raw history. It does not say which of those five entries explains why this person is on the file, and nothing in it lets you total up "revenue from people the climate petition brought us." That is the gap a model fills.

What a model is

A model is a rule that walks one person's entries and picks the single entry that deserves credit for that person. It stores four things per person:

StoredExample
the source code from the chosen entryWEB_PET_2019_climate
the date of that entry2019-03-02
the reason the rule chose itFirst Touch
the personperson_id 12345

It then stamps that same credit onto every transaction that person ever makes — the $25 in 2020 and the $50 in 2023 both count toward the climate petition. A model may optionally use a different rule for transactions than for people, but the default is "the person's credit applies to their transactions."

That one step is what makes person-level economics computable:

QuestionHow the model answers it
Lifetime value (LTV) — what is a person acquired by this effort worth over time?Sum every transaction stamped with that source code, divide by the number of people stamped with it
Acquisition ROI — did this effort pay for itself?That revenue against the acquisition cost recorded for the source code
How many people did this effort bring onto the file?Count of people stamped with that source code
Which channel produces donors who keep giving?Compare LTV across the source codes of each channel

A model is a judgment call made explicit and stored. Two models can disagree about the same person and both be correct, because they answer different questions ("who first touched them?" vs. "what most recently reactivated them?"). This is why engine9 runs several models side by side instead of choosing one, and why every stored row carries a reason.

Rules

This is the most important distinction in this document, and the easiest to get wrong.

AttributionModel
Connects a transaction toa message—
Connects a person (and all their transactions) to—an entry in that person's timeline
Question answered"Which email/mail piece produced this gift?""What brought this person onto the file?"
Scopeone transaction at a timea person's whole history
Methodlast click on the transaction's source codea rule over the timeline
Lives ine9-source-codethis document
Outputrecommended_message_id, attributed_revenue, source_code_summary.revenue{prefix}_person, {prefix}_transaction

Rule: Attribution is exclusively transaction → message. It is never a model. Do not call last click "the last-click model", list it among the models, or put it in a model comparison as if it were a peer.

Both are true at once, and they are not alternatives:

The $50 gift in June 2023 was attributed to the June mail piece — that mail piece is what produced that gift. The first touch model says this person came from the 2019 climate petition — that petition is why this person exists at all. Credit the mail piece for the gift; credit the petition for the donor.

Consequences to respect:

  • Rule: Never add attributed revenue and model revenue together; they are the same dollars counted for two different questions.
  • Model revenue for a source code will not equal that source code's attributed revenue, and that is not a bug. A petition that never asks for money can have huge model revenue and near-zero attributed revenue.
  • source_code_summary.revenue / attributed_* are attribution. The source_code_summary.origin_* columns are the old-identity model implementation. Current output is {prefix}_*. Read legacy.md only when the user explicitly asked for legacy.

Reference

Models engine9 ships

Each model is a plugin with a stable prefix. The prefix is the table name stem and is identical on every account.

ModelprefixThe rule, in words
First Touch (@engine9/plugins/models/first_touch)model_first_touchThe earliest entry of any kind. "Where did this person first show up?"
CRM Origin (@engine9/plugins/models/crm_origin)model_crm_originThe entry the CRM itself labels as the origin (CRM_ORIGIN). "What does the system of record say?" Blank when the CRM says nothing.
Last Acquisition (@engine9/plugins/models/last_acquisition)model_last_acquisitionThe most recent entry marked ACQUISITION, falling back to CRM_ORIGIN. "What most recently brought this person in?"

In all three, an explicit SOURCE_CODE_OVERRIDE entry on the timeline wins over the rule, and the stored reason says Source Code Override. That is the supported way to hand-correct one person without changing the model.

Accounts can ship additional models of their own with the same contract and the same model_<name> prefix rule; they appear alongside these in every read and comparison. Old-identity scores (person_model_source_code, pivot columns, custom legacy stems) are in legacy.md. Read that file only when the user explicitly asked for legacy models.

Effective date of an entry

Every rule above is a statement about time — earliest entry, most recent acquisition. So a model's answer depends entirely on which date each entry carries. That date is the entry's effective date: when the thing actually happened, not when engine9 loaded it. It is stored as timeline.ts, and timelinePerson shows it as effective_date.

Rule: The effective date is decided once, at load time; models only read it and never recompute it. If a model picked the "wrong" entry, check dates before questioning the rule—an entry with a bad effective date sorts to the wrong end of history and quietly changes both first touch and last acquisition.

Cascade for a single entry, first usable value wins:

OrderDate usedApplies when
1the ts the source system gave the rowThe system of record knows when it happened — send time, click time, gift date. Nearly every entry stops here.
2the input's default timestamp (below)The row has no ts, or it is a SOURCE_CODE_OVERRIDE entry whose ts is the placeholder 1970-01-01 (treated as "no date given")
3none — the row does not become a timeline entryNeither of the above produced a date

Rows loaded from a file often have no per-row date. A source code override file is the common case: it names a set of people and says "credit this source code," with no date in the file at all. The date it should land on is the date of the effort itself, so the default timestamp is derived from the source code.

Cascade for the default timestamp, first usable value wins:

OrderDate usedApplies when
1the field named by the extract_timestamp option (source_code_date or acquisition_date)Someone stated explicitly which date to use. If that field is missing or unparseable the load fails rather than guessing.
2the source code's parsed source_code_dateNo explicit choice, and the code contains a date that parsed cleanly — EM_FR_20200114_appeal → 2020-01-14
3the source code's acquisition_dateThe code has no usable date of its own, but the format's parsed values or the dictionary record when the effort acquired people
4the literal default_timestamp optionGiven, and nothing above resolved
5today's datedefault_timestamp is now or omitted

Two override values in the source code dictionary sit above the parsed value: source_code_date_override and acquisition_date_override replace what was parsed out of the code string. Use them when a code's embedded date is wrong or unparseable, rather than editing history.

A date resolved this way is normalized to a UTC calendar day (YYYY-MM-DD) and must be a real date on or after 1980-01-01. Missing, zero, or unparseable values raise an error and the file fails to load. That strictness is deliberate: a mis-parsed code should fail loudly rather than land silently at 1970 and become everyone's "first touch".

Field-level detail, date format heuristics, and the exact option names: developers.md.

File format

One run of one model produces six tables, named from its prefix:

TableOne row perHolds
{prefix}_personpersonchosen source_code_id, date_of_source, reason
{prefix}_transactiontransactionthe credit for that transaction
{prefix}_person_statssource codeperson_count — people acquired (lifetime)
{prefix}_transaction_statssource codetransactions, revenue, refund_count, refund_amount, transaction_unique_person, plus incipient_* initial-gift metrics (lifetime)
{prefix}_person_stats_by_datesource code + first-seen dayperson_count — new people that day. date is CAST(date_of_source AS DATE) = credited timeline.ts (when the person was first seen for this credit), not dictionary source_code_date
{prefix}_transaction_stats_by_datesource code + gift daysame metrics as lifetime transaction stats (including incipient_*), date = CAST(transaction.ts AS DATE)

So model_first_touch_person, model_first_touch_transaction, model_first_touch_person_stats, model_first_touch_transaction_stats, model_first_touch_person_stats_by_date, model_first_touch_transaction_stats_by_date.

Amounts and dates stay on transaction; the readable code string lives in source_code_dictionary. There is no shared cross-model table — one set per model, on purpose, so models never overwrite each other.

For source_code_summary work: join the stats tables on source_code_id when they exist (models are optional per account). Do not replace attributed revenue with model revenue. Full DDL, shipped prefixes, and optional-join rules for developers: schema.md and developers.md.

Examples

Which efforts brought the most people?

SELECT d.source_code, s.person_count
FROM model_first_touch_person_stats s
JOIN source_code_dictionary d ON d.source_code_id = s.source_code_id
ORDER BY s.person_count DESC
LIMIT 20;

Lifetime value per person acquired — all revenue those people have ever given, divided by how many of them there are:

SELECT
  d.source_code,
  p.person_count,
  t.revenue,
  t.revenue / NULLIF(p.person_count, 0) AS lifetime_value_per_person
FROM model_first_touch_person_stats p
JOIN model_first_touch_transaction_stats t ON t.source_code_id = p.source_code_id
JOIN source_code_dictionary d ON d.source_code_id = p.source_code_id
ORDER BY t.revenue DESC
LIMIT 20;

Acquisition ROI — the same revenue against what the effort cost. Requires acquisition_cost to be populated on the source code dictionary for that code; where it is not, ROI cannot be computed for that code:

SELECT
  d.source_code,
  p.person_count,
  t.revenue,
  d.acquisition_cost,
  t.revenue / NULLIF(d.acquisition_cost, 0) AS roi
FROM model_first_touch_transaction_stats t
JOIN model_first_touch_person_stats p ON p.source_code_id = t.source_code_id
JOIN source_code_dictionary d ON d.source_code_id = t.source_code_id
WHERE d.acquisition_cost > 0
ORDER BY roi DESC
LIMIT 20;

Model revenue next to attributed revenue — two different questions in two columns. Read them side by side; never sum them:

SELECT
  d.source_code,
  t.revenue AS model_revenue,        -- people this code acquired, all their giving
  scs.revenue AS last_click_revenue  -- gifts attributed to this code's messages
FROM model_first_touch_transaction_stats t
JOIN source_code_dictionary d ON d.source_code_id = t.source_code_id
LEFT JOIN source_code_summary scs ON scs.source_code_id = t.source_code_id
ORDER BY t.revenue DESC
LIMIT 20;

Every model, one source code per row: compareSourceCodes returns {prefix}_person_count / _revenue / _transactions columns for each current model that has been run, so disagreement between models is visible in one grid. With no arguments it picks each model's top codes by people and by revenue. Available over MCP as timelinePerson with command: compareSourceCodes. Pivot and custom legacy stems are in legacy.md, and only when the user explicitly asked for legacy.

Troubleshooting

To see why a person got the credit they got, read their entries and each model's stored conclusion together. MCP tool timelinePerson returns exactly that: the person's timeline entries (with input, plugin, source code, and transaction detail) plus each model_*_person row and its reason.

Read it in this order:

  1. Are the entries you expect present at all? If an acquisition entry is missing, no model can pick it — that is a timeline loading question (e9-timeline), not a model question.
  2. Does the entry carry the source code you expect? If not, that is a source code question (e9-source-code).
  3. Is each entry's effective_date right, and do the entries sort the way you expect? A date resolved from a default or a mis-parsed source code puts an entry at the wrong end of history — see §5.
  4. Given those entries in that order, did each model's rule pick correctly? The reason column says which branch fired. Only now is it a model question.

Stored reason and date_of_source reflect the last run; a model does not re-decide when you read it.

Workflow

A model is not live — someone has to run it, and the tables hold whatever the last run decided.

const model = new ModelWorker(accountWorker);
await model.run({ model: '@engine9/plugins/models/first_touch' });

run streams the timeline, applies the rule, and writes that model's six tables. Method list, test options, and authoring a new model: developers.md. Legacy tables: legacy.md, only when the user explicitly asked for legacy.

Vocabulary

TermMeaning
entryOne row on the timeline: one fact about one person at one time. Never say "event".
transactionA payment. Never say "donation" in schema or field talk.
source codeThe campaign/effort label on an entry.
effective dateWhen an entry actually happened (timeline.ts), resolved once at load time. What every model sorts by.
modelA rule that credits a person (and their transactions) to one timeline entry.
attributionConnecting one transaction to one message. Not a model.
prefixA model's table stem, e.g. model_first_touch.
reasonWhy the model chose what it chose, stored per row.

Model segment definition clauses

Each current model plugin exports two segment definition clauses. Installing the model installs both. A UI loads them from MCP segmentDefinitionClauses (GET /data/segment-definition/clauses), builds a segment definition ({ and: [{ path, options, exclude? }] }), and previews it with MCP segment command: "preview" (POST /data/segment-definition/preview). The catalog lists clauses only for plugins installed on that account.

Rule: Build the form from the catalog entry (path, title, form). form is JSON Schema { type: "object", properties, required }. options keys are the property names. exclude is not a form field; the UI sets exclude: true on the clause to mean "out of this model."

HandlerTableQuestion
personSourceCode{prefix}_personThis person's model credit is this source code
transactionSourceCode{prefix}_transactionThis person has a transaction the model credited to this source code
Pathoptions
@engine9/plugins/models/first_touch:segment_definition_clause:personSourceCodesourceCode
@engine9/plugins/models/first_touch:segment_definition_clause:transactionSourceCodesourceCode
@engine9/plugins/models/crm_origin:segment_definition_clause:personSourceCodesourceCode
@engine9/plugins/models/crm_origin:segment_definition_clause:transactionSourceCodesourceCode
@engine9/plugins/models/last_acquisition:segment_definition_clause:personSourceCodesourceCode
@engine9/plugins/models/last_acquisition:segment_definition_clause:transactionSourceCodesourceCode

sourceCode is a string and is required. A value containing % is LIKE; otherwise it is an exact match.

Catalog entry for the First Touch person handler (the other current handlers use the same form, with that model's title):

{
  "path": "@engine9/plugins/models/first_touch:segment_definition_clause:personSourceCode",
  "title": "First Touch person source code",
  "form": {
    "title": "First Touch person source code",
    "type": "object",
    "properties": {
      "sourceCode": {
        "title": "Source code",
        "description": "Exact source code, or a LIKE pattern when it contains %",
        "type": "string"
      }
    },
    "required": ["sourceCode"]
  }
}

People in First Touch for a code, and out of CRM Origin for that same code:

{
  "and": [
    {
      "path": "@engine9/plugins/models/first_touch:segment_definition_clause:personSourceCode",
      "options": { "sourceCode": "WEB_PET_2019" }
    },
    {
      "exclude": true,
      "path": "@engine9/plugins/models/crm_origin:segment_definition_clause:personSourceCode",
      "options": { "sourceCode": "WEB_PET_2019" }
    }
  ]
}

Rule: Pick the handler that matches the question. Person credit uses personSourceCode. Transaction credit uses transactionSourceCode. Excluding one does not exclude the other.

Account-specific models that export createModelSegmentDefinitionClauses show up the same way: <package>:segment_definition_clause:personSourceCode and <package>:segment_definition_clause:transactionSourceCode, with the same sourceCode form.

Rule: Legacy membership — people in person_model_source_code for a source code, including people in a legacy model and out of the matching current model — is legacy.md. Read it only when the user explicitly asked for legacy models. Do not add @engine9/plugins/models/legacy to a definition otherwise.

Related documentation

  • Running, authoring, testing, internals: developers.md
  • Legacy models (only when the user explicitly asked): legacy.md
  • Table columns and payload shapes: schema.md
  • The timeline the model reads: e9-timeline
  • Source codes and attribution (transaction ↔ message): e9-source-code
  • How person_id is decided: e9-person-id

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.