generate-rosetta-stone-mappings — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited generate-rosetta-stone-mappings (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly --> <!-- Regenerate: bun run gen:skill-docs -->
You are a data quality engineer who treats Rosetta Stone mappings as a contract between a source dataset and the normalized identity graph. You optimize for:
column stats from narrative-mcp; column names alone are not enough.
suggested.
low-confidence, not promoted to fit a quota.
You never hallucinate a Rosetta Stone attribute id, never propose a mapping from a column name in isolation, and never emit an expression that has not passed narrative_nql_validate.
*Don't surface `_nio_ field names to the user.** Columns and fields whose names start with _nio_ (e.g., _nio_last_modified_at, _nio_sample_128`) are platform-managed internals. Handle them silently as this skill instructs — filtering, skipping, or accepting auto-generated mappings — but do not name them in user-facing output: lists, tables, summaries, warnings, status messages, or final responses. Refer to them generically ("platform-managed columns", "reserved internal fields") if you need to acknowledge them at all.
Exception: if the user expressly asks about _nio_* fields, answer normally.
Map columns from a Narrative source dataset to Rosetta Stone attributes via progressive calls to the narrative-mcp server. Fetch only the schema slice, sample rows, column stats, and attribute definitions you need for each decision, and validate every expression with narrative_nql_validate (and optionally narrative_nql_execute) before suggesting it.
Without this discipline an agent will either (a) write mappings from column names alone, (b) hallucinate Rosetta Stone attribute IDs, or (c) emit SQL that fails NQL validation. Don't.
When the platform-data tools above aren't enough — e.g., you need official guidance on Rosetta Stone confidence scoring, the normalization model, or an NQL function/operator reference — consult the narrative-knowledge-base MCP server. See references/KB_RESEARCH.md for the recommended query patterns.
Triggers:
narrative.rosetta_stone."<attribute>" and a specific source datasetDo NOT use for:
Rosetta Stone attribute already covers the column, then hand off.
Run these steps in order. Steps 1-3 are mandatory context-gathering; steps 4-6 run per column being mapped; steps 7-8 finalize.
Parallelize where the calls are independent. Most steps below have fan-out points — multiple /find-attribute invocations (one per semantic cluster), a batch of narrative_attributes_describe IDs when reconfirming known attributes, a batch of narrative_nql_validate expressions. Issue these as concurrent tool calls in a single turn instead of looping serially. For very wide datasets (50+ mappable columns), consider spawning a sub-agent per column cluster so each one owns its own find → validate loop and only the final scoring is reconciled at the parent.
Most Narrative work is scoped to a company. Before any dataset, attribute, or workflow call:
narrative_context_get → check the active companyIf no company is set, or the user named a different one:
narrative_context_search_companies(search_term: "<name>")
narrative_context_set_company(companyId: <id>)narrative_context_search_companies is global-admin-only. Skip the search/set entirely if the user invoked the skill from a Narrative Platform UI session where the company is implicit (narrative_context_get returns one).
If the user gave a dataset ID, go straight to describe. Otherwise:
narrative_datasets_search(search_term: "<phrase from user>")Call narrative_datasets_describe with the dataset's ID. For wide schemas (>40 columns) or when the response is truncated, see references/DATASET_DESCRIBE.md for the split-describe pattern and underscore-prefixed-column rule.
Delegate per-column profiling to /profile-dataset rather than reading and interpreting stats inline. It owns the stats fetch, the missing/stale-stats recovery (configure → recalculate → re-read), and the null-rate / cardinality / top-value / sample-shape interpretation this skill used to carry itself:
/profile-dataset --dataset <id-from-step-2> --jsonConsume the returned profile object (see that skill's output contract): per-column null_rate, distinct_count / approx_distinct, top_values, inferred_shape, and flags. This is the evidence for every mapping decision in steps 5–7 — e.g. an inferred_shape of hash:sha256 alongside a literal type-discriminator column drives an object_mapping; a high_null_rate flag tempers the confidence score.
Input: the dataset id from step 2. Output: the structured profile object. If /profile-dataset is unavailable, fall back to step 2's bundled sample + stats and interpret them inline — the heuristics live in ../profile-dataset/references/INTERPRETATION.md.
Identify the column clusters that should resolve to attributes (individual columns for primitive mappings, groups like {type, value} or {first_name, last_name} for object mappings — see step 5).
For each cluster, delegate the catalog lookup to /find-attribute:
/find-attribute --phrase "<column semantic, e.g. 'email identifier'>" --no-confirmFire one `/find-attribute` invocation per semantic cluster in parallel — the calls are independent, and parallelism is materially faster than serializing them. The skill owns its own search + paginate + batched-describe internally; you do not need to call narrative_attributes_search or narrative_attributes_describe yourself for the candidate-discovery step.
Each /find-attribute call returns a structured result:
attribute_id: <id>
display_name: <name>
schema:
- { name: <column>, type: <type>, enum: [<values>] | null }
- …
confidence: high | medium | low
match_reason: <one-line>
alternatives:
- { attribute_id: <id>, display_name: <name>, why: <one-line> }
- …The schema field is the contract you need for step 5 — it includes type (primitive vs object), property paths (for object attributes — e.g., type, value, context.source), enum constraints (non-null enum array), and required vs optional flags. This is the only way to learn that detail; do NOT guess attribute IDs from memory or reason from search snippets.
When confidence: low or when alternatives cluster within 1-2 ranking points, treat all close candidates as in-play for that cluster and let step 5's value/object decision and step 6's expression generation discriminate.
When you already know the target attribute ID (the user named one explicitly, or you are evaluating existing mappings — see references/MODES.md), skip /find-attribute and call narrative_attributes_describe(attribute_ids: [<id>, ...]) directly — describing a known ID does not need the search + ranking machinery.
#### Map every attribute the column holds
A column can hold several attributes. Each attribute is queried on its own, and a dataset that isn't mapped to one doesn't answer queries for it unless a derivation rule fills it in. So map every attribute whose description and enum the values meet, on the rows where they meet it. Some of the shapes this takes:
geo_location of US>TX>623 holds acountry, a state and a DMA: map iso_3166_1_country, usps_state_abbreviation and designated_market_area.
sex column of M/F fitsboth hl7_gender and iab_gender, and no rule derives one from the other: map both.
state ("a state or regionwithin a country") takes the second part from every row (TX, ON, H9). usps_state_abbreviation takes it from the US rows only, and no rule derives one from the other, so map both:
CASE WHEN geo_location LIKE 'US>%'
THEN SUBSTRING_INDEX(SUBSTRING_INDEX(geo_location, '>', 2), '>', -1) ENDLeaving out the rows that don't hold an attribute isn't filtering (see "Transform values; don't filter them"). A value on a US row that the enum doesn't list, such as AE, stays.
Each mapping has to be right on its own: an attribute the values only loosely match is a wrong mapping, not an extra one. And when a derivation rule reaches an attribute from another mapping, the rule covers it (next section).
#### Map the source of a derivation, not what it derives
Derivation rules compute one attribute from another, up to three rules deep. A dataset mapped to raw_email also answers queries for normalized_email and the email hashes, so mapping the same column to those as well adds nothing. It also does harm. A stored mapping replaces the rule for its attribute on that dataset, and the attributes further down are then derived from the stored mapping instead. An inferred normalized_email that cleans addresses up differently from the rule changes every hash derived after it.
Once every cluster has its candidates, describe them all in one call:
narrative_attributes_describe(attribute_ids: [<id>, ...])The default include returns each attribute's derivations lines: derives X means a rule computes X from this attribute, possibly through others, and derived from Y means this attribute is computed from Y. Then, for each column:
holds.** An email column of plain addresses maps to raw_email only. That is usually the raw, free-form attribute: you read the column into it, and the rules derive the normalized and structured attributes from it. Choose the mappings that let the rules give the dataset the most attributes.
sha256_hashed_email derives hashed_email with type sha256_email, so a column of SHA-256 hashes maps to sha256_hashed_email, not to hashed_email. The specific attribute says what the value is; the general one needs a type to say it, and no rule runs back from it. If two attributes ever derive each other, map the more specific one for the same reason.
rows.** A device_id column next to a platform column holds IDFAs on ios rows and Android advertising ids on android rows. When apple_idfa and android_advertising_id each derive mobile_id_unique_identifier, map the column twice, each on its rows, and let the rules give the general id:
-- apple_idfa.value
CASE WHEN LOWER(platform) = 'ios' THEN TRIM(device_id) END
-- android_advertising_id.value
CASE WHEN LOWER(platform) = 'android' THEN LOWER(TRIM(device_id)) ENDWithout those rules, map mobile_id_unique_identifier once, with its type taken from platform.
derives.** If email has the candidates raw_email and normalized_email, keep raw_email. The dataset's existing mappings count too: when email already maps to raw_email, don't propose normalized_email for it.
source.** A stored mapping replaces the rule on every row of the dataset, not only where the stored value exists. Next to an email column mapped to raw_email, an email_sha256 column of stored hashes stays unmapped: mapping it would drop the hashes the rule computes from email. Don't fold the rule into the mapping either, as in COALESCE(email_sha256, SHA2(NORMALIZE_EMAIL(email), 256)); that copies a formula the rule owns.
it derives from.** A dataset of stored hashes and no plain addresses maps its hash column to sha256_hashed_email.
mapping.** The telephone_number → e164_phone_number rule only handles numbers that start with +. Put that in the telephone_number mapping's warnings; don't add an e164_phone_number mapping from the same column.
To read what a rule computes, call narrative_derivations_describe(derivation_ids: [<id>]) with the derivation id from the line.
| Source shape | Target attribute | Use |
|---|---|---|
| Single column → primitive attribute (email, phone, age, country) | Primitive | value_mapping |
Single column → object attribute where only value matters | Object with type/value | object_mapping with value, and a type set where value is |
| Multiple columns → one structured attribute (e.g., hashed-email-with-hash-type) | Object | object_mapping with property_mappings array |
| Column already produces a typed object (rare; e.g., a struct column) | Object | object_mapping mirroring the struct |
value_mapping shape:
{
"attribute_id": 123,
"mapping": {
"type": "value_mapping",
"expression": "LOWER(email_column)"
},
"confidence": 95,
"reasoning": "Column name and '@' pattern in all sampled values clearly indicate email.",
"warnings": []
}object_mapping shape:
{
"attribute_id": 456,
"mapping": {
"type": "object_mapping",
"property_mappings": [
{ "path": "type", "expression": "CASE WHEN hashed_email IS NOT NULL THEN 'sha256_email' END", "confidence": 100, "reasoning": "Discriminator, set only where there is a hash; all sampled hashes are 64 chars." },
{ "path": "value", "expression": "LOWER(hashed_email)", "confidence": 92, "reasoning": "Lowercase normalization for SHA256." }
]
},
"warnings": []
}#### Set a discriminator only where there is a value
A property that says what the value is (type, ad_platform, a context naming the source) belongs to the value. On a row with no value it would describe nothing, and the row would still read as a data point of that kind. So give it the same condition as the value: NULL wherever the value is NULL. Some examples:
person_id from member_id:value: NULLIF(TRIM(member_id), '')
type: CASE WHEN NULLIF(TRIM(member_id), '') IS NOT NULL THEN 'person_id' ENDapple_idfa from a mixed device_idcolumn takes the same platform condition in both properties:
value: CASE WHEN LOWER(platform) = 'ios' THEN TRIM(device_id) END
type: CASE WHEN LOWER(platform) = 'ios' THEN 'idfa' ENDcampaign mapping sets ad_platform only where campaign_id is: CASE WHEN campaign_id IS NOT NULL THEN 'facebook' END.
#### Don't repeat the attribute's validations
The platform applies an attribute's validations every time it reads a mapped value: a value that fails any of them becomes NULL, and a query that requires the property filters on them. $this in a validation is the mapped value.
Read them from the schema section of narrative_attributes_describe(attribute_ids: [<id>, ...]):
- validations: line. age has- validations: $this >= 0.
"validations" array on the property, inside theproperties JSON. md5_hashed_email has "validations": ["$this.\"value\" RLIKE '^[a-f0-9]{32}$'"] on value.
that attribute's type filled in, its validations included. Read them there; no extra call is needed.
Then:
LOWER(email_md5) isenough; CASE WHEN LOWER(email_md5) RLIKE '^[a-f0-9]{32}$' THEN LOWER(email_md5) END gives the same values and only makes the mapping longer. The same goes for a blank check: raw_email has "validations": ["TRIM($this.\"value\") <> ''"] on value, so TRIM(email) is enough, and NULLIF(TRIM(email), '') repeats it.
fix. Lower-case a hex hash or a device id the validation expects in lower case, or every upper-case value turns into NULL.
CASE WHEN <col_a is valid> THEN col_a WHEN <col_b is valid> THEN col_b END picks a different column on some rows without it. Keep it.
them. A property without one keeps whatever value the expression gives it.
#### Transform values; don't filter them
An expression says how to read the column as the attribute: trim, change case, parse, pad, convert units, extract a part, translate the dataset's codes into the attribute's enum. It doesn't drop values because they look invalid or look like placeholders. Deciding what to reject is the attribute's job.
CASE WHEN TRIM(email) RLIKE '^[^@ ]+@[^@ ]+[.][^@ ]{2,}$' THEN TRIM(email) END. It runs on every row, and it withdraws raw data the attribute may accept. If the attribute has no validation for clearly broken values, map them anyway and name the missing validation in warnings.
0000000000, an all-zerodevice id, the hash of '', - or ? stay as they are, unless the attribute's description or validations state a convention for them. Mention them in warnings when they are common.
TRIM(col) removessurrounding spaces. A tab or other whitespace around a value is raw data; don't strip it with a regex.
NULLIF(TRIM(col), ''), unless the attribute already has a validation that rejects a blank (see "Don't repeat the attribute's validations").
'\s', '\d' and '\w'reach Spark as the bare letters s, d and w, so REGEXP_REPLACE(email, '^\s+|\s+$', '') strips the letter s from [email protected], and validation doesn't catch it. Write a character class instead: [0-9] for a digit, [ ] for a space. See references/EXPRESSION_SYNTAX.md.
narrative_nql_validate takes a full NQL query (parameter name nql), not a bare expression. To check an expression against a dataset's schema, wrap it as a select against the dataset's table reference company_data."<dataset_id>":
narrative_nql_validate(
nql: 'select <your expression> from company_data."<dataset_id>"'
)A success response means the expression compiles against the dataset's schema. A structured error points at the offending token. If validation fails, fix the expression (see references/EXPRESSION_SYNTAX.md) and re-validate. When the local reference doesn't cover the symptom, hit the KB gotchas catalog — references/KB_RESEARCH.md lists the troubleshooting entry points (/guides/nql/troubleshooting/unsupported-type-error, /guides/nql/troubleshooting/cross-data-plane-queries, /nql/general/explicit-columns, /nql/general/reserved-keywords, /cookbooks/nql/performance-patterns). Do not suggest a mapping with an expression that has not been validated.
Validates are cheap and independent — fire all candidate expressions as concurrent tool calls in a single turn rather than serializing them.
Optionally, for high-stakes mappings or when the user asked to test, run the expression against real rows. narrative_nql_execute is asynchronous, and it hands back a workflow and its run rather than rows: find the job that run enqueued with narrative_jobs_search(workflow_run_id: "<run_id>"), then follow narrative_jobs_describe(job_ids: ["<job_id>"], include: ["result"]) until state is completed, failed, or cancelled:
narrative_nql_execute(
nql: 'select <expression> as mapped, "<source_column>" as source from company_data."<dataset_id>" limit 25'
)Use the run results to:
malformed cast)
enum_mismatch warning ifthe output values don't match the target attribute's enum
| Range | Use when |
|---|---|
| 95-100 | Clear semantic match (column name + all-sample-pattern matches), well-known standard (email, ISO timestamp, US state code, SHA256 hash). |
| 85-94 | Strong pattern with minor ambiguity (e.g., id column that is probably a user identifier given the sample). |
| 70-84 | Reasonable inference; column name ambiguous but sample data leans this way. |
| Below 70 | Multiple valid interpretations or sparse evidence. Include the suggestion but flag for user verification. |
For object_mappings, the mapping's confidence is the minimum of its property confidences. A high-confidence type literal cannot rescue a low-confidence value expression.
Show a human-readable summary, not raw JSON. Three parts:
with these fields:
contributing column.
display_name, withattribute_id in parentheses.
value_mapping, or onepath → expression line per property for object_mapping.
expressions validated against sample rows only.").
Do NOT print the underlying JSON. The user reviews the table above; the structured array is held internally for the apply hand-off in step 9 and surfaced only if the user explicitly asks for it.
Sort by confidence descending; for object_mappings, sort by the minimum property confidence.
Hold the structured mapping list in memory for step 9. Each entry follows the value_mapping or object_mapping shape from step 5 (an array of those entries, no envelope):
[
{
"attribute_id": 123,
"mapping": { "type": "value_mapping", "expression": "LOWER(email_column)" },
"confidence": 95,
"reasoning": "…",
"warnings": []
}
]If nothing is mappable, say so plainly in the summary, recommend defining a custom attribute, and name the specific columns with no Rosetta Stone equivalent. Skip step 9.
Once the human-readable summary is on screen and the structured mapping list is non-empty, ask the user whether to apply now via AskUserQuestion:
"Apply these mappings to <dataset> now?">
- Apply now — invoke/apply-rosetta-stone-mappingswith this list against<dataset>. - Apply with a dry-run first — same call with--dry-runso the rendered workflow is shown but not submitted. - Not yet — the user will apply later (offer to print the JSON on request).
On "Apply now" or "Apply with a dry-run first", hand off by calling /apply-rosetta-stone-mappings --dataset <id-from-step-2> --no-revalidate --mappings '<the bare-array JSON from step 8>' (add --dry-run for the second choice). The bare array is one of the accepted input shapes — see ../apply-rosetta-stone-mappings/references/INPUT_FORMAT.md. Pass --no-revalidate because step 6 already validated every expression against the dataset's current schema in this same conversation; the apply skill's Phase 5 re-validation would be a redundant round-trip. Do not re-render anything yourself — the downstream skill owns input normalization, the approval gate, and run polling.
Skip this step entirely when:
references/MODES.md — "Evaluate existing mappings" (the user wanted a scorecard, not a deploy) or "Improve a single mapping expression" (the output is one revised expression, not a full apply set).
The default. Follow steps 1-9 in order — gather context, validate expressions, present a human-readable summary in step 8, and offer the apply hand-off in step 9.
For alternate entry points — evaluating existing mappings or improving a single mapping expression — see references/MODES.md.
Use first person ("I analyzed 12 columns…") and conversational language ("cleaned up", not "normalized") in the summary field and in reasoning fields — these strings are user-facing in the Narrative Platform UI's Rosetta Stone normalization tab.
references/DATASET_DESCRIBE.md — narrative_datasets_describe deep semantics: the include allowlist, the split-describe pattern for wide schemas (>40 columns), and the underscore-prefixed-column rule. Read when step 2's happy-path describe isn't enough.../profile-dataset/SKILL.md — step 3 delegates per-column coverage & quality profiling here (stats fetch, missing/stale-stats recovery, null-rate / cardinality / top-value / shape interpretation). Its references/INTERPRETATION.md holds the heuristics this skill used to carry inline.references/MODES.md — alternate entry modes: evaluate existing mappings, improve a single mapping. Read if the user is touching up prior work rather than mapping a fresh dataset.references/EDGE_CASES.md — reserved-identifier quoting, enum case sensitivity, null handling, object-mapping replace-all semantics, custom-attribute fallback, confidence-vs-validity, token economy, underscore-prefixed columns. Read when an expression won't validate or a mapping feels off.references/HARNESS_FALLBACK.md — narrative-mcp unavailable (paste-driven schema + sample, confidence haircut), AskUserQuestion fallback. Read when a tool call errors or the user is outside the Narrative Platform UI.references/EXPRESSION_SYNTAX.md — SQL/NQL quoting, function, and CASE WHEN rules. Read when an expression fails narrative_nql_validate or when mapping a reserved-word column.references/ENUM_HANDLING.md — generation-vs-evaluation rules for enum-constrained attributes. Read when narrative_attributes_describe shows {value1|value2|...} constraints.references/KB_RESEARCH.md — how to query narrative-knowledge-base for Rosetta Stone best practices and NQL references when local files aren't enough.../find-attribute/SKILL.md — step 4 defers to this skill per cluster, --no-confirm, structured results.../apply-rosetta-stone-mappings/SKILL.md — downstream consumer of the bare mapping array from step 8.Silence is success. Do not call submit_feedback when the skill worked. No "great skill!" submissions, no positive feedback, no "FYI" notes.
When friction does occur, submit immediately and without asking the user. submit_feedback is append-only telemetry — it is not a user-visible action and does not require confirmation. If you noticed something missing, unclear, incorrect, surprising, or that wasted your time, file it the moment you've worked around it. Do not defer the submission to a post-task recap, and do not ask the user "want me to submit feedback?" — that's the wrong default for this tool.
One submission per distinct friction point. Submit liberally.
Fields that matter most:
skill_name: narrative-common:generate-rosetta-stone-mappings (use this verbatim).severity: info (nit) | friction (slowed you down) |blocker (stopped you).
category: missing_info | unclear_instructions |incorrect_instructions | unexpected_behavior | tool_failure | other.
summary: one concrete line — what went wrong, not how you felt.suggested_improvement: the sentence or paragraph that, if addedto this skill, would have eliminated the friction. This is the highest-value field — be specific, quote the skill text you'd change.
Optional but useful when known: details, task_context, agent_model, time_lost_minutes.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.