Skip to content

Writing Your First Spec

This guide walks through writing a complete Kanoniv identity spec from scratch. By the end, you will have a working spec that matches customer records across a CRM export and a billing system, with fuzzy name matching, blocking, survivorship, and tuned decision thresholds.

What You'll Build

The use case is Customer 360: you have two CSV files with overlapping customer data.

  • CRM contacts (crm_contacts.csv) -- columns: id, full_name, email_address, phone_number, company
  • Billing customers (stripe_customers.csv) -- columns: customer_id, name, email, phone, plan, mrr, tags

Column names differ between the two systems. Some customers appear in both. Your spec will find the overlaps, score them, and produce golden records.

The Minimal Spec

Start with the smallest valid spec: one entity, one source, one rule, and a decision threshold.

yaml
api_version: kanoniv/v2
identity_version: "1.0"

entity:
  name: customer

sources:
  crm:
    adapter: csv
    location: crm_contacts.csv
    primary_key: id

rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0

decision:
  thresholds:
    match: 0.9
    review: 0.7

This is enough to validate:

python
from kanoniv import Spec, validate

spec = Spec.from_file("customer-spec.yaml")
result = validate(spec)
print(result.valid)  # True

The spec works, but it only has one source. Identity resolution needs at least two data sets to compare.

Adding a Second Source

Add the billing CSV. The billing system uses different column names, so use attributes to map them to canonical field names that your rules can reference uniformly.

yaml
sources:
  crm:
    adapter: csv
    location: crm_contacts.csv
    primary_key: id
    attributes:
      name: full_name
      email: email_address
      phone: phone_number

  billing:
    adapter: csv
    location: stripe_customers.csv
    primary_key: customer_id
    attributes:
      name: name
      email: email
      phone: phone
      plan: plan
      mrr: mrr
      tags: tags

The keys in attributes are canonical names -- the names your rules use. The values are the actual column names in the source file. Both sources now expose name, email, and phone as canonical fields, even though the underlying columns are different.

The CRM source maps email_address to the canonical email. The billing source maps email to email (same name, but still worth being explicit). Now a rule referencing email will work across both sources.

Watch for Split vs. Combined Name Fields

If one source has first_name / last_name and another has a single name column, map them to the same canonical field your rules reference. A rule on first_name will score 0.0 against records that only have name — silently producing no signal. See the Sources reference for a detailed example.

Writing Matching Rules

Rules define the signals the engine uses to decide whether two records are the same entity. Add them incrementally.

Rule 1: Exact email match

Email is the strongest single identifier for customers. If two records share the same email address, they are very likely the same person.

yaml
- name: email_exact
  type: exact
  field: email
  weight: 1.0

Weight 1.0 -- this is the strongest signal in the spec. An exact email match should carry maximum influence.

Rule 2: Fuzzy name match

Names have typos, abbreviations, and formatting differences. "Jane Smith" and "Jayne Smyth" are likely the same person. Use a similarity rule with the Jaro-Winkler algorithm, which is optimized for short strings like names.

yaml
- name: name_fuzzy
  type: similarity
  field: name
  algorithm: jaro_winkler
  threshold: 0.88
  weight: 0.6

The threshold of 0.88 means the engine only counts a name pair as matching if the Jaro-Winkler similarity score is at least 0.88. This is high enough to catch "Jane Smith" / "Jayne Smyth" (score ~0.91) but low enough to reject "Jane Smith" / "John Smith" (score ~0.78).

Weight 0.6 -- names are a useful signal but not definitive. Two different people can share a name. It should support a match decision but not drive one alone.

Rule 3: Exact phone match

Phone numbers are strong identifiers when present, but they get recycled and are often missing.

yaml
- name: phone_exact
  type: exact
  field: phone
  weight: 0.85

Weight 0.85 -- strong but slightly below email, because phone numbers change hands more often than email addresses.

Why these weights

The weights reflect your confidence in each signal:

RuleWeightRationale
email_exact1.0Email is the most reliable customer identifier
phone_exact0.85Strong identifier, but phones get recycled
name_fuzzy0.6Supportive signal; common names produce false positives

Weights do not need to sum to 1.0. The engine normalizes scores by dividing the weighted sum of matched rules by the sum of all weights.

Configuring Blocking

Without blocking, the engine compares every record against every other record. For n records, that is n * (n - 1) / 2 comparisons. With 50,000 records across your two sources, that is 1.25 billion pairs. Blocking groups records into buckets by shared key values and only compares within each bucket.

yaml
blocking:
  strategy: exact
  keys: [email, phone]

This creates two sets of blocking buckets: one keyed on email values and one keyed on phone values. Two records are candidate pairs if they share the same email or the same phone number. Records that share neither are never compared, which eliminates the vast majority of pair evaluations.

The exact strategy is the right default for clean identifiers like email and phone. If your data has more noise (e.g., misspelled names as the only shared field), consider phonetic or ngram strategies instead.

Setting Decision Thresholds

After all rules are evaluated for a pair, the engine calculates a normalized score between 0.0 and 1.0. The thresholds determine what happens next.

yaml
decision:
  scoring: weighted_sum
  thresholds:
    match: 0.9
    review: 0.7

This creates three zones:

Score RangeDecisionWhat Happens
>= 0.9MatchRecords are merged automatically
0.7 -- 0.89ReviewSent to the review queue for human decision
< 0.7RejectRecords remain separate

Score walkthrough

Consider two records that match on email and name, but not phone.

Rule contributions:

RuleWeightFires?Raw Score
email_exact1.0Yes1.0 * 1.0 = 1.000
name_fuzzy0.6Yes (0.91)0.6 * 0.91 = 0.546
phone_exact0.85No0.85 * 0.0 = 0.000
Normalized score = (1.000 + 0.546 + 0.000) / (1.0 + 0.6 + 0.85) = 1.546 / 2.45 = 0.631

Score 0.631 falls in the reject zone (below 0.7). Even though email and name matched, the missing phone match dragged the score down. This is the weighted sum at work -- all rules contribute to the denominator whether they fire or not.

Now consider a pair where all three rules fire (email match, name score 0.92, phone match):

Score = (1.0 + 0.552 + 0.85) / 2.45 = 2.402 / 2.45 = 0.980

Score 0.980 is a confident match.

If the first scenario produces too many false rejections, you have two options: lower the review threshold (e.g., to 0.5) to catch more borderline pairs in review, or reduce the phone rule's weight so a missing phone match does not penalize the score as heavily.

Denominator Dilution

The weighted sum divides by all rule weights, not just the ones that fired. This means adding more rules with high weights can actually lower confidence scores for pairs that don't match on every signal. If you see real matches stuck in the review queue with scores just below the merge threshold, check whether unmatched rules are dragging the denominator up. Either lower those rules' weights or lower the merge threshold.

Adding Survivorship

When records are merged, you need to decide which source's value wins for each field. Without survivorship rules, the engine picks arbitrarily.

yaml
survivorship:
  default: source_priority
  overrides:
    - field: email
      strategy: most_recent
    - field: tags
      strategy: aggregate
      function: union
  • default: source_priority -- for most fields, prefer the CRM value (sources are prioritized in the order they appear in the spec). If CRM has a null value, fall back to billing.
  • email: most_recent -- email addresses change. The most recently updated record's email is most likely current.
  • tags: aggregate with union -- tags from both sources are merged into a single deduplicated list. If CRM has ["strategic"] and billing has ["high-value"], the golden record gets ["strategic", "high-value"].

The Complete Spec

Here is the full assembled spec:

yaml
api_version: kanoniv/v2
identity_version: "1.0"

entity:
  name: customer

sources:
  crm:
    adapter: csv
    location: crm_contacts.csv
    primary_key: id
    attributes:
      name: full_name
      email: email_address
      phone: phone_number

  billing:
    adapter: csv
    location: stripe_customers.csv
    primary_key: customer_id
    attributes:
      name: name
      email: email
      phone: phone
      plan: plan
      mrr: mrr
      tags: tags

rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0

  - name: name_fuzzy
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.88
    weight: 0.6

  - name: phone_exact
    type: exact
    field: phone
    weight: 0.85

blocking:
  strategy: exact
  keys: [email, phone]

decision:
  scoring: weighted_sum
  thresholds:
    match: 0.9
    review: 0.7

survivorship:
  default: source_priority
  overrides:
    - field: email
      strategy: most_recent
    - field: tags
      strategy: aggregate
      function: union

Save this as customer-spec.yaml.

Validate and Run

Use the Python SDK to validate, plan, and reconcile:

python
from kanoniv import Spec, Source, validate, plan, reconcile

# Load and validate
spec = Spec.from_file("customer-spec.yaml")
validate(spec).raise_on_error()

# Preview the execution plan
execution = plan(spec)
print(execution.summary())

# Load sources
sources = [
    Source.from_csv("crm", "crm_contacts.csv"),
    Source.from_csv("billing", "stripe_customers.csv"),
]

# Run reconciliation
result = reconcile(sources, spec)
print(f"Clusters: {result.cluster_count}")
print(f"Golden records: {len(result.golden_records)}")
print(f"Merge rate: {result.merge_rate:.1%}")

validate() catches structural and semantic errors before you run anything. plan() shows you exactly what the engine will do -- which rules fire in what order, how blocking reduces comparisons, and any risk flags. reconcile() executes the full pipeline and returns clusters, golden records, and per-pair decision traces.

If the merge rate looks wrong (too high means false positives, too low means false negatives), adjust thresholds and weights. Run plan() again to check for risk flags before each reconcile.

Next Steps

  • Spec Reference -- full reference for every top-level key, validation rules, and tier-gated features
  • Rules Reference -- all rule types (exact, similarity, range, composite, ml), blocking strategies, and weight guidelines
  • Survivorship Reference -- all strategies (source_priority, most_recent, most_complete, aggregate, custom) with worked examples
  • Python SDK -- complete API reference for Spec, Source, validate, plan, diff, and reconcile
  • Source Adapters -- connect to PostgreSQL, Snowflake, BigQuery, Pandas DataFrames, dbt models, and more

The identity and delegation layer for AI agents.