Why
An analyst has only the synthetic table and their own code – the
custodian kept the original and the recipe. Their question is the one
every downstream user of a masque clone eventually asks:
what do I actually get back from the custodian, and what happens the
day the real data does not quite match what I built against? This
vignette is written for that analyst’s side of the workflow: what the
recipe carries, how the round-trip works, and what it does when the two
sides of the trust boundary disagree.
What
The masque_recipe object returned by mask()
is the only artefact that must stay confidential alongside the
original – it is an S7 object, accessed through recipe(),
never touched directly. Two verbs carry the round-trip:
apply_recipe() translates a data frame from the original
namespace into the synthetic namespace (the direction a custodian uses
to re-target an analyst’s finished pipeline), and unmask()
translates the other way (the direction that recovers original-namespace
labels from a synthetic-namespace result).
save_recipe()/read_recipe() persist a recipe
to a single .rds file, and reveal_maps() is
the one explicit, warning-gated call that shows the level maps a
redacted print() withholds.
Do
What a recipe holds
m <- mask(df, roles, mode = "collaborate", seed = 1L)
rec <- recipe(m)
class(rec)
#> [1] "masque::masque_recipe" "S7_object"A recipe is runtime-minimal by default. It carries:
-
masque_version,created_at,mode,seed– provenance. -
roles– the two-axis role and action table. -
level_maps– the per-column original-to-alias maps. The sensitive part. -
column_name_map– the column-name aliases, whenalias_nameswas used (otherwiseNULL). -
cleaning– the hygiene record (name legalisation, whitespace trims) soapply_recipe()re-applies the identical tidying. -
storage_classes,factor_meta– the original column classes and factor metadata, for faithful reconstruction. -
coords– one entry per coordinate pair declared tomask()’scoordsargument: the jitter method and radius, the site grouping it was masked under, and how many sites the grouping produced. Empty for a recipe written before 0.10.0, which had no such record. -
allow_unmasked_coords–TRUEonly when the caller deliberately wrote a real coordinate through unmasked.FALSE, and absent, on a recipe written before 0.11.0. -
integrity_fp– a SHA-256 ofis.na(original): an integrity fingerprint, not a privacy guarantee.
It deliberately does not hold the copula covariance, the raw observed values, or any file paths or usernames.
A recipe built with coords carries its own coordinate
account, so an audit of the recipe does not need the original data
back:
df_geo <- df
df_geo$lat <- -34.9 + stats::runif(nrow(df_geo), -0.05, 0.05)
df_geo$lon <- 138.6 + stats::runif(nrow(df_geo), -0.05, 0.05)
roles_geo <- propose_roles(df_geo, mode = "collaborate")
roles_geo <- set_role(roles_geo, "yield", role = "outcome")
roles_geo <- set_role(roles_geo, c("lat", "lon"), action = "keep")
m_geo <- mask(df_geo, roles_geo, mode = "collaborate", seed = 1L,
coords = list(list(lat = "lat", lon = "lon")))
n_sites_recorded <- recipe(m_geo)@coords[[1]]$n_sites
recipe(m_geo)@coords[[1]][c("method", "min_km", "max_km", "n_sites")]
#> $method
#> [1] "donut"
#>
#> $min_km
#> [1] 5
#>
#> $max_km
#> [1] 20
#>
#> $n_sites
#> [1] 72
recipe(m_geo)@allow_unmasked_coords
#> [1] FALSEEvery row here shared one true coordinate, so n_sites
equals the row count – the recipe records that the whole table was
jittered as one group, not each row independently, which is the
confidentiality property jitter_coordinates(by = ) is
for.
Printing is redacted
The print method shows the role and action table with a marker for
which columns hold a level map (* mapped, =
not), but never the vocabularies themselves:
rec
#>
#> ── masque_recipe ───────────────────────────────────────────────────────────────────────────────────
#> • Created: 2026-09-05 06:32:27 UTC
#> • Mode: collaborate
#> • Clone fidelity: marginal / structural (global copula)
#> • Seed: present (redacted)
#> • masque version: 0.12.0
#> • Integrity fingerprint: 0cec319ba9e2...
#>
#> ── Columns (7 total; 1 level-map(s); 0 column-name map(s)) ──
#>
#> = design keep plot (integer)
#> = design keep rep (factor)
#> = design keep block (factor)
#> * treatment alias gen (factor)
#> = outcome scramble yield (numeric)
#> = design keep row (integer)
#> = design keep col (integer)
#>
#> ✖ PRIVATE - never share this recipe alongside the synthetic.
#> Use `reveal_maps(rec)` to inspect level maps explicitly.The custodian – and only the custodian – can reveal the maps with an explicit, warning-gated call:
reveal_maps(rec)
#> ! Revealing sensitive level maps. Proceed at your discretion.
#>
#> ── gen
#>
#> ── seedThe round-trip
The recipe is a bidirectional translator. apply_recipe()
carries the original into the synthetic namespace.
unmask() carries results back. The usual pattern
is to fit on the synthetic, then score the original:
# Analyst trains on the synthetic clone.
fit <- lm(yield ~ gen, data = synthetic(m))
# Custodian re-targets the same pipeline onto the real data.
orig_in_synth_space <- apply_recipe(df, rec)
preds <- predict(fit, newdata = orig_in_synth_space)
preds_match_nrow <- length(preds) == nrow(df)
preds_match_nrow
#> [1] TRUERecovering original-namespace labels is symmetric:
fwd <- apply_recipe(df, rec)
back <- unmask(fwd, rec)
labels_recovered <- identical(as.character(back$gen), as.character(df$gen))
labels_recovered
#> [1] TRUEColumns whose action is keep pass through all three
verbs unchanged. Numeric columns are passed through
apply_recipe() and unmask() without an inverse
map – the synthetic and original namespaces coincide for them – so
unmask() on a numeric prediction vector is a safe
no-op.
Fail-closed translation, and reading the refusal
If the data drifts – a new treatment level the recipe has never seen
– apply_recipe() errors rather than silently coercing the
unknown value to NA:
drifted <- df
levels(drifted$gen) <- c(levels(df$gen), "BRAND_NEW")
drifted$gen[1] <- "BRAND_NEW"
apply_recipe(drifted, rec)
#> Error in `.fail_unmapped()`:
#> ! Value(s) not in the recipe's level map in column gen: "BRAND_NEW".
#> ℹ Schema drift or new original-namespace level(s). Unknown values are not coerced to NA
#> (fail-closed).
#> • Rebuild the recipe from a dataset that contains these values, or strip them before retargeting.Reading the message: it names the exact column (gen) and
the exact unrecognised value ("BRAND_NEW"), states plainly
that unknown values are not silently coerced to NA, and
gives the remedy – rebuild the recipe from a dataset that contains the
new value, or strip the drifted rows before re-targeting. This is
deliberate: a model matrix built from a silently-introduced
NA would fail somewhere downstream with a far less legible
error, or worse, would not fail at all and would simply drop the drifted
rows. Schema drift is exactly the kind of change that should stop a
pipeline at the point it happens, not several steps later.
Saving the recipe
save_recipe() writes a single runtime-minimal
.rds – small, and safe to store next to the original at the
same security class. read_recipe() validates the file and
notes a version mismatch without raising an error.
tmp <- tempfile(fileext = ".rds")
save_recipe(rec, tmp)
rec2 <- read_recipe(tmp)
fingerprint_survives_roundtrip <- identical(rec@integrity_fp, rec2@integrity_fp)
fingerprint_survives_roundtrip
#> [1] TRUEMulti-table bundles
A mask_set() result carries a recipe bundle –
one recipe per table plus the shared cross-table link maps. The same
apply_recipe() and unmask() verbs dispatch on
it, operating table by table over a named list:
set_dir <- system.file("extdata", "met_set", package = "masque")
ms <- mask_set(set_dir, mode = "collaborate", seed = 1L, quiet = TRUE)
#> Warning: Numeric environment column(s) year remain "keep" in collaborate mode.
#> ℹ This preserves environment structure but may disclose year or other numeric labels; review before
#> release.
originals <- read_set(set_dir)
fwd_set <- suppressWarnings(apply_recipe(originals, recipe(ms)))
names(fwd_set)
#> [1] "agronomy" "quality"Because the shared keys were aliased consistently at masking time, the re-targeted tables join on the same keys the synthetic tables do.
Figure: the clone the analyst actually develops against
Everything above works with the labels. The figure below shows what the analyst’s numeric column actually looks like – the original yield against the synthetic yield the analyst’s pipeline is fitted to – which the round-trip checks above, all of them logical values, cannot show.
synth_tbl <- synthetic(m)
overlay_df <- rbind(
data.frame(value = df$yield, table = "original"),
data.frame(value = synth_tbl$yield, table = "synthetic")
)
ggplot2::ggplot(overlay_df, ggplot2::aes(x = value, fill = table)) +
ggplot2::geom_density(alpha = 0.5) +
ggplot2::scale_fill_viridis_d(name = "table") +
ggplot2::labs(
x = "yield (t/ha)", y = "density",
title = "Original versus synthetic marginal distribution of yield"
) +
ggplot2::theme_minimal()
Density of yield, original trial against the synthetic clone the analyst develops against. The two distributions overlap closely because the default numeric synthesis preserves each column’s marginal distribution.
Read
The round-trip holds on every check run above: the re-targeted
prediction vector has one entry per original row (TRUE), the recovered
genotype labels are identical to the original after a forward-then-back
trip through the recipe (TRUE), and a recipe written to disk and read
back carries an unchanged integrity fingerprint (TRUE). The coordinate
account records 72 sites for a fixture where every plot shared one true
coordinate, which is the row count of the trial – confirming the whole
table was jittered as one group rather than plot by plot. The
fail-closed section shows the other side of the same reliability
property: a value the recipe has never seen is refused, not guessed at,
so a pipeline re-targeted through apply_recipe() fails at
the point the data actually drifted rather than downstream with a
harder-to-diagnose error.
The figure shows why the round-trip’s logical checks are not the whole story: the two densities overlap closely, which is what “the marginal distribution is preserved” looks like on real data, but overlap in the marginal says nothing about whether a treatment effect or a non-linear covariate relationship survived – for that, see Confidentiality and the threat model.
Answering the opening question: the recipe an analyst is handed back after their pipeline is finished lets a custodian re-target that exact pipeline, unmodified, onto the real data, and the moment the real data carries something the recipe was never built from, the translation stops rather than guessing.
Limits
Everything in this vignette exercises one recipe built from one clean
public trial with no missing structure and no schema drift beyond the
one planted for the fail-closed demonstration; a recipe built from a
table with genuinely inconsistent historical vocabularies will refuse
more often, by design, and the remedy each time is the same – rebuild
from data that contains the missing values, or strip the drifted rows.
The round-trip verbs invert exactly what the recipe recorded: a numeric
column has no inverse map and passes through unchanged, so
unmask() cannot recover anything about a numeric
transformation a pipeline applied on its own, and a recipe cannot repair
a join that was broken outside masque before masking ever
ran. This vignette does not exercise the leakage audit, the conditional
clone, or the geographic-coordinate masking controls in depth; those,
and what the recipe alone cannot protect against a determined reader of
the synthetic, are Confidentiality and the threat model’s
subject.
What to read next
Getting started with masque is the custodian’s side of this same handoff: building and reviewing the masking plan that produces the recipe this vignette reads. Confidentiality and the threat model sets out what the recipe and the synthetic together do and do not protect, including the leakage audit and the conditional clone that preserves a treatment effect this vignette’s round-trip checks do not touch.
Reproduce
set.seed(1) is set once for the whole document, and
every mask()/ mask_set() call also passes
seed = 1L explicitly, so each is independently reproducible
regardless of what ran before it in this vignette. Package versions
follow.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8 LC_COLLATE=C.UTF-8
#> [5] LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8 LC_PAPER=C.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] masque_0.12.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 jsonlite_2.0.0 compiler_4.6.1 maps_3.4.3
#> [5] jquerylib_0.1.4 systemfonts_1.3.2 scales_1.4.0 textshaping_1.0.5
#> [9] yaml_2.3.12 fastmap_1.2.0 ggplot2_4.0.3 R6_2.6.1
#> [13] labeling_0.4.3 knitr_1.51 tibble_3.3.1 desc_1.4.3
#> [17] bslib_0.12.0 pillar_1.11.1 RColorBrewer_1.1-3 rlang_1.3.0
#> [21] cachem_1.1.0 xfun_0.60 fs_2.1.0 sass_0.4.10
#> [25] S7_0.2.2 otel_0.2.0 viridisLite_0.4.3 cli_3.6.6
#> [29] pkgdown_2.2.1 withr_3.0.3 magrittr_2.0.5 digest_0.6.39
#> [33] grid_4.6.1 lifecycle_1.0.5 vctrs_0.7.3 evaluate_1.0.5
#> [37] glue_1.8.1 data.table_1.18.6.1 farver_2.1.2 ragg_1.5.2
#> [41] rmarkdown_2.32 tools_4.6.1 pkgconfig_2.0.3 htmltools_0.5.9