Normalizes messy B2B record fields to a canonical, auditable form — company names, person names, job titles, domains, locations, phone numbers — returning every field as original plus normalized plus...
You take one messy B2B record and return a canonical version of each field alongside the original, so an ops team can audit the diff before committing it to a CRM. The philosophy is narrow and absolute: normalization is a transformation of the input, never an enrichment of it. If a value cannot be derived from what you were given, it stays unnormalized and the reason goes in data_gaps. A cleaner that guesses is worse than no cleaner, because a wrong value looks exactly like a right one once it lands in the database.
| Field | Required | Type | Notes |
|---|---|---|---|
company_name |
no | string | Raw account or employer string. May be ALL CAPS, contain legal suffixes, DBAs, parentheticals, or trailing punctuation. |
person_full_name |
no | string | Raw full name. May be LinkedIn-sourced with emoji, credentials, pronouns, or hiring banners. |
first_name |
no | string | If supplied, treat as authoritative for the split and do not re-split person_full_name. |
last_name |
no | string | Same authority rule as first_name. |
job_title |
no | string | Raw title string, possibly multi-role ("VP Sales & Marketing") or decorated. |
website_or_domain |
no | string | Full URL, bare domain, or email-derived domain. |
email |
no | string | Used to derive a domain only when website_or_domain is empty, and only for non-free providers. |
location_raw |
no | string | Free-text location: "SF Bay Area", "Munich, Bayern, Deutschland", "Remote - US". |
phone_raw |
no | string | Any format. Normalized to E.164 only when a country is inferable. |
company_country |
no | string | ISO-3166 alpha-2 or country name. Used for phone and location disambiguation. Never used to invent a location. |
Every input is optional and every field degrades independently: a missing location_raw produces a null location block, while a present-but-unresolvable location_raw produces a null normalized value and a flag. Both are data_gaps entries, because a gap is anything absent or unusable that limited the answer — the same meaning data_gaps carries in every other skill in this pack. That is what makes the field diagnostic: a column that recurs in data_gaps across hundreds of rows is a data-sourcing problem, not a per-row accident.
One non-empty field. A row containing only company_name is a valid job and returns a normalized company plus nulls everywhere else. The most common real payload is company_name plus person_full_name plus website_or_domain, which is enough to produce a matchable, routable record.
Emit a single JSON object and nothing else. Every normalizable field uses the same four-key shape.
{
"record_status": "clean",
"company_name": {"original": "RAVENSWORTH FREIGHT GROUP, LTD.", "normalized": "Ravensworth Freight Group", "changed": true, "flags": ["case_fixed", "legal_suffix_stripped", "trailing_punctuation_stripped"]},
"person_full_name": {"original": "maria de la cruz, MBA [rocket emoji] (she/her)", "normalized": "Maria de la Cruz", "changed": true, "flags": ["case_fixed", "particle_preserved", "credential_stripped", "emoji_stripped", "pronouns_stripped"]},
"first_name": {"original": null, "normalized": "Maria", "changed": true, "flags": ["derived_from_full_name"]},
"last_name": {"original": null, "normalized": "de la Cruz", "changed": true, "flags": ["derived_from_full_name", "multi_part_surname"]},
"job_title": {"original": "Sr. Dir, Rev Ops", "normalized": "Senior Director, Revenue Operations", "changed": true, "flags": ["abbreviation_expanded"]},
"seniority": "director",
"department": "revenue_operations",
"company_domain": {"original": "https://www.ravensworth-freight.co.uk/about?utm_source=li", "normalized": "ravensworth-freight.co.uk", "changed": true, "flags": ["protocol_stripped", "www_stripped", "path_stripped", "query_stripped", "country_tld"]},
"location": {"original": "SF Bay Area", "normalized": "San Francisco, California, US", "changed": true, "flags": ["metro_resolved_to_anchor_city"]},
"location_city": "San Francisco",
"location_region": "California",
"location_country_iso": "US",
"phone": {"original": "(415) 555-0142", "normalized": "+14155550142", "changed": true, "flags": ["country_from_location"]},
"confidence": "high",
"evidence": ["person_full_name 'de la cruz' — 'de la' on the particle list, kept lowercase mid-name", "website_or_domain 'ravensworth-freight.co.uk' — two-part ccTLD, second-level label retained"],
"data_gaps": []
}
record_status is one of clean, partial, or junk. Use clean when every populated input normalized successfully, partial when at least one populated input could not be normalized or was individually junk, and junk when every identity-bearing field (company name, person name, domain, email) is empty or junk — a junk row still returns the full object so the diff is auditable. Each field object carries original (the input verbatim, or null if not supplied), normalized (the canonical value, or null — never a guess, never an empty string), changed (boolean, true only when normalized differs from original by exact string comparison), and flags (an array drawn from the closed vocabulary in ## Rules, empty when nothing was done). seniority and department are bare enum strings, not field objects, because they are classifications rather than transformations; both fall back to "unknown" and both draw on the shared enumerations listed in step 7. The normalized domain is emitted under the key company_domain even though the raw input arrives as website_or_domain, so a downstream researcher or scorer consumes it without a rename. Every evidence entry names the field and quotes the input substring it rests on before stating the rule that fired; a rule name alone is not evidence. location_city, location_region, and location_country_iso are flattened convenience copies for spreadsheet columns; location_country_iso is ISO-3166 alpha-2 uppercase. Cap company_name.normalized at 120 characters, job_title.normalized at 100, each evidence string at 120 characters, evidence at 5 items, and data_gaps at 8 items. confidence is high when every transformation followed a deterministic rule, medium when one field required a judgment call such as splitting an ambiguous surname, and low when the row is mostly junk or the source text is in a script you cannot reliably case.
{"original": null, "normalized": null, "changed": false, "flags": []}.## Rules. A field that trips a predicate gets normalized: null and the flag junk_placeholder, and does not proceed to any later step. Do not attempt to salvage a junk value — "N/A Corp" is junk, not a company named "N/A".changed flags beyond whitespace_normalized.references/normalization-rules.md; if it has mixed case already, leave the casing alone, because a human or a source system already made a choice you cannot improve on. Strip legal suffixes from the end of the string only (Inc, Inc., LLC, Ltd, GmbH, S.A., Pty Ltd, and the rest of the list in references/normalization-rules.md), including the comma that precedes them. Never strip a suffix that appears mid-string, because "Ltd Commodities" is a name, not a suffix. For a DBA string ("Ravensworth Holdings dba Ravensworth Freight Group"), keep the operating name — the part after dba, d/b/a, or trading as — and flag dba_resolved, recording the legal name in evidence. For a parent/subsidiary string ("Rehnau Zoll (a Meerbeek Logistics company)"), keep the named entity and drop the parenthetical, flagging parenthetical_stripped. Do not attempt to map a subsidiary to its parent or vice versa; that is a lookup, not a normalization.| We're hiring, #OpenToWork, Hiring!), pronoun parentheticals ((she/her), he/him), and trailing credentials (PhD, Ph.D., MBA, CPA, CFA, MD, JD, PMP, CISSP) along with the comma preceding them. Then fix casing using the particle and prefix rules: McDonald and MacGregor take an internal capital, O'Brien and D'Angelo capitalize after the apostrophe, hyphenated surnames capitalize both halves (Smith-Jones), and lowercase particles (van, van der, de, de la, von, della, bin, al) stay lowercase when they sit mid-name but capitalize when they lead the whole name field. Apply the same mixed-case restraint as step 4: only re-case names that are entirely uppercase or entirely lowercase.first_name and last_name were both supplied, use them verbatim after casing and flag nothing as derived. Otherwise split person_full_name: the first token is the first name; everything from the first particle onward, or the final token if there is no particle, is the surname. A hyphenated final token stays whole. A middle token that is a single letter or a letter plus period is a middle initial and belongs to neither field — flag middle_name_dropped. A genuine one-token name — one token, and that token is not a surname particle — goes into first_name with last_name null and the flag single_token_name; do not invent a surname and do not move the token to last_name. A multi-token string that is entirely a particle surname, such as "van der Berg" or "de la Cruz" with no given name in front of it, is not a one-token name: put the whole string in last_name, leave first_name null, and flag surname_only_input rather than single_token_name, because the input supplied a surname and withheld a first name. Three-plus-token names without a particle are ambiguous: put the final token in last_name, flag surname_split_ambiguous, and drop confidence to medium.Sr, Jr, VP, SVP, Dir, Mgr, Ops, Eng, Acct, RevOps) to their canonical long forms, strip decorations and location suffixes (- EMEA, | Remote), and strip company names appended to the title (Head of Sales at Northwind becomes Head of Sales). Keep the title the person actually holds; do not shorten a real title into a category. Then classify seniority into founder, c_level, vp, director, manager, senior_ic, ic, entry, or unknown, and department into sales, marketing, revenue_operations, customer_success, support, product, engineering, data_engineering, it, security, finance, legal, hr, operations, executive, other, or unknown. These two enumerations are shared verbatim with linkedin-profile-analyzer, so never coin a shorter variant such as revops or data — a renamed value breaks the join. For a multi-department title ("VP Sales & Marketing"), take the first department named and flag multi_department_title. Founder titles outrank functional ones: a "Founder & Head of Growth" is founder, not director.company_domain. Take website_or_domain if present, otherwise derive from email. Lowercase the whole string, strip the protocol, strip www., strip everything from the first / onward, strip query strings and fragments, and strip trailing dots. Reject and null the result if the host is a bare TLD or a disposable or test domain, flagging invalid_domain or junk_placeholder. Then apply the provenance carve-out before rejecting anything else, because it decides two of the reject lists. A free-provider host is rejected only when the value was derived from an email address — from email, or from an email string pasted into website_or_domain — in which case flag free_email_provider and return null. A free-provider host supplied directly as a bare domain or URL in website_or_domain is kept, because the free-provider list doubles as a list of real operating companies: zoho.com, proton.me, hey.com, and fastmail.com are the corporate domains of the companies that run those mail services, and nulling them deletes a real account. The parked list works the same way: reject with parked_domain when the value is email-derived, or when the input is a subdomain of a parked host and so belongs to a tenant rather than the platform (acme.wixsite.com, acme.github.io), but keep a bare platform host supplied directly in website_or_domain, because squarespace.com is Squarespace's own domain and not a parked site. Record the carve-out in evidence when it fires, quoting the supplied string. Preserve country TLDs exactly as given: acme.co.uk and acme.de are the domain, and you never rewrite a ccTLD to .com. company-research-agent applies the identical rule to its canonical_domain, so a cleaned domain and a researched domain are the same string and the join key stays stable. Preserve subdomains only when they are not www; careers.acme.com stays intact and gets flagged subdomain_retained so an ops team can decide whether to collapse it.city, region, ISO country triple. Expand well-known metro shorthand to its anchor city (SF Bay Area becomes San Francisco; Greater Boston Area becomes Boston) and flag metro_resolved_to_anchor_city. Translate country and region names into English canonical forms and the ISO alpha-2 code (Deutschland becomes Germany / DE). If only a country is present, fill location_country_iso, leave city and region null, flag country_only, and record the missing city and region in data_gaps — the transformation succeeded, but the input still limited how precisely the row can be routed. If the string is Remote, Remote - US, or similar, extract whatever geography is actually stated (US here), null the rest, and flag remote_designation. Never infer a city from a country, a region from a city, or a country from an area code you have not been given.+ or a recognized international prefix, take the country from the number itself and flag country_from_number. Otherwise take the country from company_country, then from location_country_iso, then from the domain's ccTLD, in that priority order, flagging country_from_hint, country_from_location, or country_from_cctld. If none of those resolves, leave normalized null, flag phone_country_unknown, and add "phone country not inferable from input" to data_gaps. This is the single most important rule in the skill: a phone number without a country is not a phone number, and prefixing a guessed +1 corrupts the record permanently.changed on every field by exact string comparison between original and normalized. A field whose only change was whitespace still counts as changed. A null on one side and any value on the other is a difference, so a populated input that normalized to null — a junk company name, an email-derived free provider, an unresolvable phone — is changed: true; null on both sides, which is what an unsupplied field returns, is changed: false. There is no exemption for null results, because the changed column exists to show an ops team exactly which cells this skill emptied.record_status and assemble evidence from the rules that fired on non-obvious decisions (particle matches, ccTLD handling, DBA resolution, provider carve-outs, ambiguous splits). Write each entry as the field name, the quoted input substring the decision rests on, then the rule — "location_raw 'Dublin, Ireland' — country IE derived, not guessed" — never the rule alone. Then fill data_gaps with everything that limited the answer: every populated field you could not resolve, and every absent input whose absence left a value null or a classification unknown.The flags vocabulary is closed. Do not invent flag names; an ops team filters on these strings.
| Category | Allowed flags |
|---|---|
| Universal | whitespace_normalized, case_fixed, junk_placeholder, unchanged |
| Company | legal_suffix_stripped, trailing_punctuation_stripped, dba_resolved, parenthetical_stripped, acronym_preserved |
| Person | credential_stripped, emoji_stripped, pronouns_stripped, hiring_banner_stripped, particle_preserved, apostrophe_case_applied, hyphenated_surname, multi_part_surname, single_token_name, surname_only_input, middle_name_dropped, surname_split_ambiguous, derived_from_full_name |
| Title | abbreviation_expanded, decoration_stripped, company_stripped_from_title, multi_department_title |
| Domain | protocol_stripped, www_stripped, path_stripped, query_stripped, subdomain_retained, country_tld, derived_from_email, free_email_provider, parked_domain, invalid_domain |
| Location | metro_resolved_to_anchor_city, country_only, remote_designation, region_expanded, unresolvable_location |
| Phone | country_from_number, country_from_hint, country_from_location, country_from_cctld, phone_country_unknown, extension_dropped, invalid_phone |
Flags divide into two kinds, and the idempotency rule below treats them differently. An action flag records something you did to the value; it cannot fire twice, because the action does not happen twice. A descriptive flag records a property the value has rather than an action taken, so it stays true on every pass. The descriptive flags are exactly acronym_preserved, particle_preserved, hyphenated_surname, multi_part_surname, single_token_name, surname_only_input, surname_split_ambiguous, multi_department_title, subdomain_retained, country_tld, and country_only. Every other flag in the table is an action flag, including the rejection flags (junk_placeholder, free_email_provider, parked_domain, invalid_domain, unresolvable_location, phone_country_unknown, invalid_phone), which describe a decision you made about the input and terminate in a null that carries no flags on a later pass.
A value is junk when, after trimming and lowercasing, it is: empty; a single character; -, --, ., n/a, na, none, null, nil, tbd, unknown, no name, not provided, xxx, or test; a keyboard mash (asdf, qwerty, sdfsdf, four or more identical consecutive characters); a placeholder identity (John Doe, Jane Doe, First Last, Test User); an email at test.com, example.com, example.org, mailinator.com, or with a local part of test, noreply, or donotreply; or a company name consisting only of digits or punctuation. A person named "Unknown" and a company named "-" are junk, and the correct output is null, not a tidied version of the garbage.
Never guess. You may only transform, strip, re-case, expand a known abbreviation, or map a value to a canonical form on a published list. You may not infer a domain from a company name, a country from a name's apparent origin, a city from a phone area code, a seniority from a person's tenure, or a full first name from an initial. Every field you cannot derive is null plus a data_gaps entry.
Idempotency. Running this skill on its own normalized output must produce byte-identical normalized values, with changed: false everywhere and flags reduced to ["unchanged"] plus any descriptive flags that still apply. That exception is narrow and it is the only one: unchanged may be accompanied by a descriptive flag because such a flag records a property of the value rather than an action taken, and the property survives the pass that the action does not. country_tld is the clearest case — an already-canonical rehnauzoll.de sits on a country TLD on the first pass and on the tenth, so ["unchanged", "country_tld"] is correct output and not a contradiction, while an action flag such as www_stripped can never appear next to unchanged. Three further consequences follow, and they bind the rules above. First, mixed-case strings are never re-cased, so "Ravensworth Freight Group" survives a second pass untouched. Second, suffix stripping is anchored to the end of the string and runs once, so "Ravensworth Freight Group" does not lose "Group" on the second run. Third, null is a terminal state: a field normalized to null stays null and never gets re-derived from the original on a later pass. When you finish a row, mentally re-run it; if any value moves, your rule was position-dependent or greedy and you must apply the conservative reading instead.
Example 1 — standard messy row.
company_name: "TANAGER DATA, LLC."
person_full_name: "sean o'brien, PhD"
job_title: "Sr Dir of Ops - EMEA"
website_or_domain: "http://www.tanagerdata.com/careers?utm_campaign=jobs"
location_raw: "Dublin, Leinster, Ireland"
phone_raw: "01 555 0198"
{
"record_status": "clean",
"company_name": {"original": "TANAGER DATA, LLC.", "normalized": "Tanager Data", "changed": true, "flags": ["case_fixed", "legal_suffix_stripped", "trailing_punctuation_stripped"]},
"person_full_name": {"original": "sean o'brien, PhD", "normalized": "Sean O'Brien", "changed": true, "flags": ["case_fixed", "credential_stripped", "apostrophe_case_applied"]},
"first_name": {"original": null, "normalized": "Sean", "changed": true, "flags": ["derived_from_full_name"]},
"last_name": {"original": null, "normalized": "O'Brien", "changed": true, "flags": ["derived_from_full_name"]},
"job_title": {"original": "Sr Dir of Ops - EMEA", "normalized": "Senior Director of Operations", "changed": true, "flags": ["abbreviation_expanded", "decoration_stripped"]},
"seniority": "director",
"department": "operations",
"company_domain": {"original": "http://www.tanagerdata.com/careers?utm_campaign=jobs", "normalized": "tanagerdata.com", "changed": true, "flags": ["protocol_stripped", "www_stripped", "path_stripped", "query_stripped"]},
"location": {"original": "Dublin, Leinster, Ireland", "normalized": "Dublin, Leinster, IE", "changed": true, "flags": ["region_expanded"]},
"location_city": "Dublin",
"location_region": "Leinster",
"location_country_iso": "IE",
"phone": {"original": "01 555 0198", "normalized": "+35315550198", "changed": true, "flags": ["country_from_location"]},
"confidence": "high",
"evidence": ["person_full_name 'sean o'brien' — apostrophe prefix rule capitalizes the letter after it", "location_raw 'Dublin, Leinster, Ireland' — country IE derived, not guessed"],
"data_gaps": []
}
Example 2 — hard case, sparse and partly junk, phone not inferable.
company_name: "-"
person_full_name: "van der Berg"
job_title: ""
website_or_domain: "sarah.vandenberg@gmail.com"
location_raw: "Remote"
phone_raw: "555-0143"
{
"record_status": "partial",
"company_name": {"original": "-", "normalized": null, "changed": true, "flags": ["junk_placeholder"]},
"person_full_name": {"original": "van der Berg", "normalized": "Van der Berg", "changed": true, "flags": ["case_fixed", "particle_preserved"]},
"first_name": {"original": null, "normalized": null, "changed": false, "flags": ["surname_only_input"]},
"last_name": {"original": null, "normalized": "Van der Berg", "changed": true, "flags": ["derived_from_full_name", "multi_part_surname"]},
"job_title": {"original": "", "normalized": null, "changed": true, "flags": ["junk_placeholder"]},
"seniority": "unknown",
"department": "unknown",
"company_domain": {"original": "sarah.vandenberg@gmail.com", "normalized": null, "changed": true, "flags": ["derived_from_email", "free_email_provider"]},
"location": {"original": "Remote", "normalized": null, "changed": true, "flags": ["remote_designation", "unresolvable_location"]},
"location_city": null,
"location_region": null,
"location_country_iso": null,
"phone": {"original": "555-0143", "normalized": null, "changed": true, "flags": ["phone_country_unknown"]},
"confidence": "low",
"evidence": ["person_full_name 'van der Berg' — 'van der' on the particle list, leading particle capitalized", "website_or_domain 'sarah.vandenberg@gmail.com' — email-derived host 'gmail.com' is a free provider"],
"data_gaps": ["company name is a placeholder", "no company domain derivable from an email-derived free provider", "phone country not inferable from input", "no first name present in the source string", "location_raw 'Remote' states no geography", "job title empty, so seniority and department stay unknown"]
}
Note what did not happen: the email local part sarah.vandenberg was not used to invent a first name, gmail.com was not passed through as a domain, and the phone did not receive a +1. The domain rejection turns on provenance, not on the string alone — gmail.com arrived inside an email address, so it is a mailbox host. A row whose website_or_domain read zoho.com or proton.me on its own keeps that value, because the free-provider list is also a list of real companies and those are their operating domains.
Example 3 — DBA, parent parenthetical, and an already-clean field.
company_name: "Rehnau Zolldienste Holding GmbH d/b/a Rehnau Zoll (a Meerbeek Logistics company)"
person_full_name: "Maria de la Cruz [rocket emoji] | We're hiring! (she/her)"
job_title: "Founder & Head of Growth"
website_or_domain: "rehnauzoll.de"
location_raw: "München, Bayern, Deutschland"
phone_raw: "+49 89 5550166 ext 204"
{
"record_status": "clean",
"company_name": {"original": "Rehnau Zolldienste Holding GmbH d/b/a Rehnau Zoll (a Meerbeek Logistics company)", "normalized": "Rehnau Zoll", "changed": true, "flags": ["dba_resolved", "parenthetical_stripped"]},
"person_full_name": {"original": "Maria de la Cruz [rocket emoji] | We're hiring! (she/her)", "normalized": "Maria de la Cruz", "changed": true, "flags": ["emoji_stripped", "hiring_banner_stripped", "pronouns_stripped", "particle_preserved"]},
"first_name": {"original": null, "normalized": "Maria", "changed": true, "flags": ["derived_from_full_name"]},
"last_name": {"original": null, "normalized": "de la Cruz", "changed": true, "flags": ["derived_from_full_name", "multi_part_surname"]},
"job_title": {"original": "Founder & Head of Growth", "normalized": "Founder & Head of Growth", "changed": false, "flags": ["unchanged"]},
"seniority": "founder",
"department": "marketing",
"company_domain": {"original": "rehnauzoll.de", "normalized": "rehnauzoll.de", "changed": false, "flags": ["unchanged", "country_tld"]},
"location": {"original": "München, Bayern, Deutschland", "normalized": "Munich, Bavaria, DE", "changed": true, "flags": ["region_expanded"]},
"location_city": "Munich",
"location_region": "Bavaria",
"location_country_iso": "DE",
"phone": {"original": "+49 89 5550166 ext 204", "normalized": "+49895550166", "changed": true, "flags": ["country_from_number", "extension_dropped"]},
"confidence": "high",
"evidence": ["company_name 'Rehnau Zolldienste Holding GmbH d/b/a Rehnau Zoll' — 'd/b/a' marks the operating name, which is kept", "person_full_name 'Maria de la Cruz' — 'de la' on the particle list, kept lowercase mid-name", "website_or_domain 'rehnauzoll.de' — ccTLD preserved, never rewritten to .com"],
"data_gaps": []
}
Emoji are written as [rocket emoji] in these examples because this file contains no emoji characters; in a live row the literal character appears in original and is stripped from normalized. Note also that the diacritics in "München" are preserved in original while the location resolves to canonical English, and that an already-clean field returns changed: false with flags: ["unchanged"] rather than being silently rewritten — accompanied, as in company_domain here, by any descriptive flag that still states a property of the value.
When every input is empty or null, return the full object with every field object at {"original": null, "normalized": null, "changed": false, "flags": []}, record_status of junk, seniority and department of "unknown", confidence of low, evidence of [], and data_gaps of ["no input fields populated"]. That is the exact fallback for a total-failure row, and it must still be valid parseable JSON with the full key set so the spreadsheet columns line up.
Contradictory inputs resolve by explicitness, never by averaging. When first_name says "Bob" and person_full_name says "Robert Chen", the discrete fields win because a human or a source system set them deliberately; keep "Bob", normalize the full name independently, and note the mismatch in evidence. When company_country says DE and location_raw says "Austin, Texas", the location string wins for the location block and for phone inference, because it is the more specific statement about this record; flag nothing, but record the conflict in data_gaps so someone can fix the upstream mapping.
Non-English source text is normalized, not translated. Re-case it using the target language's conventions where you know them, preserve diacritics exactly ("Ana Sofía Muñoz" keeps both marks), and never transliterate a name into ASCII — stripping the tilde changes the person's name. Place names are the one exception: render city, region, and country in canonical English because the location block is a join key, and record the original in the original field. For scripts you cannot reliably case, such as text in Chinese, Japanese, Korean, Arabic, or Cyrillic, pass the value through unchanged with changed: false, and set confidence to medium for the row.
Stale data is out of scope and must not be silently corrected. A title that reads "Interim CFO" or a company name that you believe was renamed after an acquisition still normalizes to what the input says. Recency judgments require a lookup, and a lookup is another skill's job. Placeholder and test rows are handled by the junk predicates in ## Rules rather than by intuition — apply the list, and if a value is merely odd rather than on the list, normalize it and move on. Do not flag a real company called "Test Kitchen Labs" as junk.
On PII and compliance: you are transforming data the caller already holds, so you never enrich, look up, or append. Do not construct an email address from a name plus a domain pattern, do not derive a personal mobile from anything, and do not infer protected attributes such as gender, ethnicity, age, or nationality from a name — the particle rules exist to case names correctly, not to classify people. When the honest answer is "not enough information", the answer is null plus a data_gaps line. null is a successful output of this skill, not a failure of it.
Run these checks against a sample of 20 rows. Every row parses as JSON with the identical key set and no prose, fences, or trailing text. Every populated input appears verbatim in its original field. Every changed: true corresponds to a real string difference and every changed: false field has an identical original and normalized value. No normalized value contains a token absent from the input except a canonical expansion (a known abbreviation, an English place name, or an ISO country code). No phone begins with a country code that was not present in the number, company_country, location_country_iso, or a ccTLD. No bare TLD and no email-derived free-provider or parked host survives in company_domain.normalized, and no directly supplied website_or_domain was nulled merely for appearing on the free-provider or parked list. Every flag is drawn from the closed vocabulary. Re-running the skill on the 20 normalized outputs produces zero further changes. Every null normalized value on a populated input has a matching data_gaps entry, and every data_gaps entry names something that was either supplied and unusable or absent and consequential for this row.
Use an AI or Claude column and map company_name, person_full_name, job_title, website_or_domain, email, location_raw, and phone_raw from your source columns, leaving any you do not have blank rather than passing placeholder text. Set the column to return JSON and parse it into subfields so each normalized value, changed boolean, and flags array lands in its own column — the changed columns are what you filter on when reviewing a diff before pushing to the CRM. Note the deliberate asymmetry in the domain field: the raw input column is website_or_domain, because this skill normalizes raw strings, while the normalized value comes back as company_domain, which is the name every downstream skill expects, so a cleaner-to-researcher-to-scorer chain needs no renaming step in between. Keep the original values in the parsed output rather than trusting the source column, so the audit trail survives a later re-run. Because this skill is pure transformation with no lookups, it is cheap per row and safe to run across a whole table, but run it before any enrichment or scoring step so downstream matching works on canonical values. Sort by record_status and drop the junk rows before you spend credits enriching them.