Status: design proposal. Nothing described here is implemented yet. Like
datalinking-design.md, this document exists to fix a
contract before code depends on it — and, in this case, to be explicit about
what a policy layer can and cannot achieve, because the failure mode of a
privacy feature is a false sense of safety.
The first slice is implemented and specified separately:
policy-demonstrator.md, v0.1.0 — ODRL attached to
files, regions and variants over single-sample fixtures, with per-request views
verified against an oracle. It implements a subset of this document and cites
the sections it defers.
VCF-RDFizer currently makes one decision about disclosure: it converts
everything. Every sample, every genotype, every header line, every free-text
Description goes into the graph, and every artifact is all-or-nothing. The
only granularity available today is "convert a different VCF".
What is wanted is the ability to say, in a machine-readable and auditable way: these samples, under this purpose, may see these regions at this resolution — and to have the tool produce artifacts that actually honour it.
Genotypes are identifiers. A few dozen independent common variants are enough to pick one individual out of a population (Lin, Owen & Altman, Science 2004), aggregate allele frequencies alone can reveal whether a known individual was in a cohort (Homer et al., PLoS Genetics 2008), and Y-chromosome haplotypes have been used to recover surnames from public genealogy databases (Gymrek et al., Science 2013).
Three consequences follow, and they shape everything below:
- Removing
vcfc:sampleIddoes not anonymize anything. The genotype vector that remains is a stronger identifier than the label you deleted. - Access control is not anonymization. A policy layer governs who is permitted to receive what, and creates an audit trail. It does not make the released data non-identifying, and nothing in this design should be described as if it did.
- The artifact is the enforcement boundary. Once someone holds a
.hdtfile, no policy engine is in the loop. Query-time enforcement only exists while you control the endpoint.
This document therefore proposes a system for governed release, not for anonymity. Where a mechanism reduces re-identification risk (thresholding, generalization), it says by how much and under what assumption.
The graph has a small number of natural cut planes, and they map cleanly onto
the shapes the conversion already emits (see
conversion.md):
| Cut | Addresses | Example |
|---|---|---|
| By class | all resources of a type | every vcfc:SampleCall |
| By predicate | all triples with a predicate | every vcfc:sampleName |
| By sample | one participant's contribution | everything hanging off #samples/NA12878 |
| By region | a genomic interval | chr19:44,905,791-44,909,393 (APOE) |
| By declared field | one INFO or FORMAT key | DP yes, GT no |
| By record predicate | an arbitrary graph pattern | records where FILTER != PASS |
| By granularity | not a subgraph at all | counts permitted, individual calls not |
The last row is the one that access control alone cannot express, and it is the one that matters most for the attacks in §1. It is handled in §9.
A note on scope. Two families of triple leak more than people expect and are easy to forget when thinking in terms of genotypes:
- IRIs themselves.
file://cohort.vcf#sample/1/NA12878contains the sample name.#record/{ROW_ID}is a monotonic counter, so row identifiers disclose the source ordering — and therefore approximate genomic position — even ifvcfc:posis dropped. And{SOURCE_FILE}is the VCF's basename, which in practice is oftenpatient_12345.vcf. Filtering triples while leaving IRIs intact leaks membership. See §7. - Header lines.
vcfc:headerValueand the raw line text carry##source(pipeline and centre identifiers),##SAMPLEand##PEDIGREE(family structure, by design), free-textDescriptionfields, and##fileDate. A policy that covers genotypes and ignores the header section has not covered the graph.
Yes for the policy layer, no as the whole answer. Being precise about the gap is what makes the rest of the design tractable.
What ODRL gives you, that is genuinely hard to get elsewhere:
- A W3C Recommendation, so a policy is a citable, interoperable artifact rather than a bespoke config file.
- RDF-native: the policy lives in the same graph model as the data, can be published, dereferenced, versioned and signed.
- The vocabulary the problem actually needs —
odrl:permission/odrl:prohibition/odrl:duty,odrl:assignee,odrl:constraintwithodrl:purposeandodrl:recipientleft operands. - A profile mechanism designed for exactly this situation: extend the core with domain terms without forking it.
What ODRL does not give you, and must be supplied:
| Gap | Consequence |
|---|---|
odrl:target points at an Asset (an IRI). There is no notion of "these triples". |
A selector vocabulary is required. This is the core technical work. |
| Evaluation semantics in the ODRL Information Model are informative, not normative. | Conflict resolution and rule ordering must be pinned by the profile, or two implementations will disagree. |
| Rules permit or prohibit. They cannot say how to degrade. | An effect/transform vocabulary is required (drop, generalize, threshold, pseudonymize). |
| No enforcement engine exists. | Enforcement is ours to build; ODRL is the declaration, not the mechanism. |
Alternatives considered:
| Option | Verdict |
|---|---|
| SHACL shapes as policy | Excellent at describing a permitted shape, and worth reusing to verify a release (§11) — but it is a validation language with no notion of party, purpose or duty. Not a policy language. |
| Solid WAC / ACP | Resource-granular. A VCF graph is one resource; the whole problem is sub-resource granularity. |
| XACML | Mature and battle-tested, but not RDF-native, heavyweight, and it would put the policy outside the data ecosystem the rest of the tool lives in. |
| Bare SPARQL views | Simplest possible enforcement, and in fact what the compiler emits — but as a policy it records no party, purpose, obligation or provenance, so it cannot be audited or presented to a data access committee. |
Recommended shape: ODRL as the front end, a VCF-RDFizer profile supplying selectors and effects, compiled down to a plain internal release plan. Keep the enforcement layer independent of ODRL so a second front end (a DUO-only consent file, say) can be added without touching the engine.
Namespace vcfp: = https://w3id.org/vcf-rdfizer/policy#, declared through
odrl:profile.
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
@prefix vcfp: <https://w3id.org/vcf-rdfizer/policy#> .
@prefix vcfc: <https://w3id.org/vcf-core/vocab#> .
@prefix duo: <http://purl.obolibrary.org/obo/> .
<#apoe-locus> a odrl:Asset , vcfp:GraphSelection ;
vcfp:selector [ a vcfp:RegionSelector ;
vcfp:assembly "GRCh38" ;
vcfp:chrom "chr19" ;
vcfp:start 44905791 ;
vcfp:end 44909393 ] .
<#direct-identifiers> a odrl:Asset , vcfp:GraphSelection ;
vcfp:selector [ a vcfp:PredicateSelector ;
vcfp:predicate vcfc:sampleName , vcfc:sampleId ] .
<#withdrawn-participants> a odrl:Asset , vcfp:GraphSelection ;
vcfp:selector [ a vcfp:SampleSelector ;
vcfp:sampleId "NA12878" , "NA12891" ] .| Selector | Keys | Notes |
|---|---|---|
vcfp:ClassSelector |
vcfp:class |
Every resource asserting that type, and its outbound triples |
vcfp:PredicateSelector |
vcfp:predicate |
Triple-level; the cheapest to enforce |
vcfp:SampleSelector |
vcfp:sampleId, vcfp:sampleIndex |
Resolves through SampleSet membership |
vcfp:RegionSelector |
vcfp:assembly, vcfp:chrom, vcfp:start, vcfp:end |
vcfp:assembly is mandatory and checked against ##reference; a mismatch aborts, for the same reason it does in the linking design |
vcfp:FieldSelector |
vcfp:formatKey, vcfp:infoKey |
By declared field id |
vcfp:HeaderSelector |
vcfp:headerKey |
##SAMPLE, ##PEDIGREE, ##source, … |
vcfp:PatternSelector |
vcfp:ask |
A SPARQL graph pattern. The escape hatch; expensive, and excluded from the cheap enforcement tiers (§5) |
Selectors compose with vcfp:allOf / vcfp:anyOf / vcfp:not. ODRL's own
odrl:AssetCollection with odrl:refinement is deliberately not reused
here: its refinement semantics are about collection membership, not about
sub-graph extents, and overloading it would produce policies that look standard
while meaning something non-standard.
A prohibition that can only mean "drop everything" is too blunt for genomic
data, where the useful answer is usually less resolution, not nothing. The
profile therefore refines odrl:duty with a transform:
vcfp:transform |
Effect |
|---|---|
vcfp:drop |
Omit matched triples entirely (default for a prohibition) |
vcfp:pseudonymize |
Replace a term with a keyed, per-release token (§7) |
vcfp:generalize |
Reduce resolution: POS → 1 Mb window, GT → carrier / non-carrier, DP → banded |
vcfp:threshold |
Suppress a value below a count, e.g. allele count < 5 |
vcfp:aggregateOnly |
The subgraph is reachable only through a counting query (§9) |
vcfp:maskVectorPositions |
Condensed mode only: rewrite the tab-separated literal, replacing masked sample positions with . (§8) |
<#cohort-release-1.2> a odrl:Set ;
odrl:uid <https://example.org/policy/cohort-release/1.2> ;
odrl:profile <https://w3id.org/vcf-rdfizer/policy> ;
odrl:conflict odrl:prohibit ;
# Hard exclusion: withdrawn consent. No purpose overrides this.
odrl:prohibition [
odrl:target <#withdrawn-participants> ;
odrl:action odrl:read ;
odrl:assignee odrl:All ] ;
# Degrade rather than deny: APOE is readable only as carrier status.
odrl:permission [
odrl:target <#apoe-locus> ;
odrl:action odrl:read ;
odrl:duty [ odrl:action odrl:anonymize ;
vcfp:transform vcfp:generalize ;
vcfp:genotypeResolution vcfp:carrierStatus ] ] ;
# Purpose-bound permission, expressed with GA4GH DUO codes.
odrl:permission [
odrl:target <#genotypes> ;
odrl:action odrl:read ;
odrl:assignee <https://example.org/party/consortium-b> ;
odrl:constraint [ odrl:leftOperand odrl:purpose ;
odrl:operator odrl:isAnyOf ;
odrl:rightOperand duo:DUO_0000007 ] ;
odrl:duty [ odrl:action odrl:inform ;
vcfp:auditSink <https://example.org/audit> ] ] .odrl:purpose right operands should be GA4GH Data Use Ontology terms rather
than free strings. DUO is what data access committees and repositories already
speak, so this makes a policy reviewable by the people who actually grant
access, and it means the tool is not inventing a consent vocabulary.
Pin the DUO release in the policy (vcfp:duoVersion) and resolve term IRIs
against that release — DUO evolves, and a policy that silently re-interprets a
consent code is worse than one that fails to load.
Because ODRL leaves this informative, the profile normatively fixes it:
odrl:conflict odrl:prohibitis the default and the only value recommended for a release policy. Deny wins.- A rule with no applicable transform defaults to
vcfp:drop. - Effects on the same selection compose most-restrictive-first
(
drop>aggregateOnly>threshold>generalize>pseudonymize). odrl:conflict odrl:invalidis supported for strict deployments: any conflict voids the policy and the run aborts rather than guessing.- Anything not matched by a permission is denied. Default-deny is the only defensible posture; a default-allow policy language for genomic release is a trap.
Three tiers, in increasing cost, falling directly out of the existing pipeline
(architecture.md). The compiler
assigns each rule to the cheapest tier that can express it.
Row and column removal applied to <sample>.records.tsv and
<sample>.header_lines.tsv before RMLStreamer runs.
This is by far the cheapest option and the only one that covers RML-produced
triples as well as wrapper-produced ones, because nothing downstream ever sees
the excluded data. It handles SampleSelector (drop a sample column),
RegionSelector (drop rows — CHROM and POS are columns 3 and 4, right there),
and HeaderSelector (drop header rows).
Its limit is granularity: it removes whole rows and columns, so it cannot
express "keep this record but drop its DP".
The three direct emitters
(architecture.md) hold the
full parsed record while they emit, so a predicate-, field- or value-level rule
costs one branch per emitted triple and needs no second pass.
Concrete hook points in vcf_rdfizer.py:
| Where | Change |
|---|---|
_append_rdf_atomically(rdf_path, stats, producer) |
Wrap emit in a policy filter; the atomic-append and rollback behaviour is unchanged |
append_expanded_sample_rdf, append_condensed_sample_rdf |
Sample- and field-level rules; the condensed one also needs §8 |
append_record_detail_rdf |
QUAL and INFO field rules |
append_header_representation_rdf |
Header rules |
One small prerequisite. ParsedSampleRecord currently carries
source_file, row_id, qual, info, format_keys, sample_payloads and
sample_values — _parse_row reads columns 0, 1, 7, 9, 10 and −1, and skips
CHROM and POS (columns 2 and 3). A RegionSelector evaluated at emission time
needs both. That is two extra fields on the dataclass and two extra index reads
in _parse_row, with no measurable cost, and it should be the first commit in
this work.
For applying a policy to a graph that has already been converted
(--mode redact --rdf <existing>.nt.gz --policy <p>.ttl).
Triple- and predicate-level rules are a single streaming pass. Region rules
are not, because an N-Triples stream has no ordering guarantee: by the time
you see <…#sample/4711/NA12878> vcfc:fieldValue "0/1", the triple that told
you record 4711 is at chr19:44906000 may be long gone. This needs two passes:
pass one builds a row_id → (chrom, pos) map restricted to records that fall in
a policy region, pass two filters. Memory scales with the number of in-scope
records, not the graph, which keeps it bounded in practice — but it is
materially more expensive than doing the same work in Tier 1 or 2, and the
documentation should say so rather than let users discover it.
A policy can also be compiled to SPARQL rewriting at an endpoint. It is worth building, but it is not the primary enforcement point for this tool, for a reason worth stating plainly: VCF-RDFizer's principal output is files that people take away. An HDT or COTTAS artifact handed to a collaborator is outside any policy engine forever. Query-time enforcement protects an endpoint you operate; materialization protects a file you ship. Most users of this tool need the second.
Where an endpoint is operated, note also that rewriting is easy to get subtly
wrong: a prohibition enforced with FILTER NOT EXISTS still lets a caller infer
the excluded set from counts and negative results. Query-time enforcement should
be paired with the aggregate controls in §9, not treated as sufficient alone.
ODRL policy (.ttl)
│ parse, validate against the profile, resolve DUO version
▼
rule set ──▶ conflict resolution (§4.4) ──▶ release plan
│
├── Tier 1 ops: TSV row/column predicates
├── Tier 2 ops: per-triple predicates, keyed by emitter
├── Tier 3 ops: post-hoc stream filters (+ any two-pass requirements)
└── residual: rules no tier can enforce ──▶ ABORT
The residual set must abort the run, never warn. A privacy rule that was parsed, reported, and then not applied is the single worst outcome this design can produce: the operator believes the release is governed and it is not. If the compiler cannot enforce a rule, it must refuse to produce an artifact.
The release plan is a plain data structure with no ODRL dependency, which keeps the engine testable in isolation and leaves room for a second front end.
Because IRIs embed sample names, the source filename and a positional row
counter (§2), filtering triples is not enough. Under vcfp:pseudonymize the
release view must re-mint IRIs.
Construction. token = base32(HMAC-SHA256(release_key, namespace ‖ value)),
truncated to a documented length, where namespace distinguishes sample names
from row ids from filenames so the same string in two roles does not produce the
same token.
Properties this gives, and the ones it does not:
- Stable within a release, so joins inside the released graph still work.
- Unlinkable across releases, because
release_keyis fresh per release — unless linkage is deliberately wanted, in which case a named, reused key is an explicit policy choice recorded in the manifest. - The key is never written into the artifact or the manifest; only its identifier and algorithm are. Re-identification remains possible for whoever holds the key, which is the point — this is pseudonymization, not anonymization, and the manifest should use that word.
- It does not defeat genotype-based re-identification (§1). Nothing here does.
Row-id re-minting deserves specific attention: replacing #record/4711 with a
token removes the ordering leak, but only if the tokens are emitted in an order
that does not reconstruct it. Sort the released graph by token, not by source
order.
This one is specific to VCF-RDFizer and easy to miss until it produces a leak.
In expanded mode, one participant's value is its own resource:
<…#sample/4711/NA12878/fmt/GT> vcfc:fieldValue "0/1" .
Excluding a sample is triple filtering. Straightforward.
In condensed mode, all participants' values for one FORMAT key live in one literal:
<…#call/4711/matrix/fmt/GT> vcfc:encodedValues "0/1\t0/0\t1/1\t./."^^vcfc:VCFTextVector .
There is no triple to remove for one sample. Graph-pattern access control cannot express "sample 2 only" over this shape at all — the unit of protection is finer than the unit of storage.
Two consequences:
- Redaction must rewrite the literal.
vcfp:maskVectorPositionsreplaces the masked participant's token with., which is the VCF missing marker and keeps the vector aligned withvcfc:sampleIndex— alignment the format depends on. Dropping a position instead would silently shift every downstream sample's value, which is a data-corruption bug wearing a privacy feature's clothing. - Masking is itself visible. A
.where neighbours have values discloses that a value existed and was withheld, and combined withSampleSetmembership it discloses whose. Where that matters, the policy must mask the position across all samples for that record, or drop the vector entirely. The compiler should detect a single-position mask and warn — or, under a strict profile setting, refuse.
A blunter option is worth offering: vcfp:requireRepresentation expanded, which
makes a policy refuse to run against a condensed graph. For policies with
per-sample rules that is often the honest answer, and it trades storage
efficiency for enforceability deliberately rather than by accident.
Access control does not address §1's attacks, all of which operate on data the recipient was permitted to see. Three mechanisms, in increasing ambition:
Count thresholds (vcfp:threshold). Suppress a variant whose allele count
falls below k. Cheap, well understood, and directly targets the
rare-variant-as-fingerprint problem, since rare alleles carry most of the
identifying signal. Implementable in Tier 2 with a counting pre-pass.
Recommended default for any cohort release: suppress AC < 5, and say so in the
manifest.
Aggregate-only exposure (vcfp:aggregateOnly). The subgraph is not
materialized; only counting queries over it are answerable. This is the Beacon
model, and it inherits the Beacon model's known weakness — repeated membership
queries leak — so it must be paired with query budgeting and audit, not offered
as a safe default.
Differential privacy. Noise on aggregate counts is the only mechanism here with a formal guarantee, and it is also the easiest to implement incorrectly: a per-query epsilon with no global budget provides no protection at all against a patient adversary. Not proposed for implementation. If it is added later it needs a persistent budget ledger per recipient, and the design should say plainly that without one it is decoration.
Every policy-derived artifact carries provenance, in the same spirit as the
linkset node in datalinking-design.md:
<file://cohort-release.nt#release>
a vcfp:ReleaseView ;
vcfp:derivedFrom <file://cohort.vcf> ;
vcfp:policy <https://example.org/policy/cohort-release/1.2> ;
vcfp:policyDigest "sha256:9f2c…" ;
vcfp:duoVersion "2024-11-03" ;
vcfp:pseudonymKeyId "release-2026-09-key-3" ;
vcfp:representation vcfc:ExpandedRepresentation ;
vcfp:triplesWithheld 1840221 ;
vcfp:samplesWithheld 2 ;
vcfp:thresholdApplied 5 ;
prov:generatedAtTime "2026-09-04T11:04:22Z"^^xsd:dateTime .Three requirements that are easy to get wrong:
- The policy is referenced by digest, not just by IRI. A policy IRI whose content changed later cannot explain an artifact produced last year.
- A release view gets its own IRI namespace, so it can never be silently mistaken for, or merged with, the full graph.
- Counts of what was withheld are published. They are not themselves sensitive at this granularity, and they are what makes a release auditable rather than merely asserted.
A privacy claim that is not checked is a privacy claim that is wrong. This is where the existing validation harness earns its keep a second time.
Policy conformance checks. Compile every prohibition into a SPARQL ASK (or
a SELECT COUNT) that must return zero over the released artifact, and run them
as preflights, exactly as preflight_blank_nodes and friends already work. A
release that fails one is not published. This turns "the APOE region was
excluded" from a claim into a measurement — the distinction
validation-methodology.md is built around.
SHACL for shape-level guarantees. "No vcfc:sampleName appears anywhere" is
naturally a shape constraint, and the --shacl-shapes layer already exists.
Mutation testing the redactor. Add a mutation class to
test/validation_mutations.py that
deliberately breaks the redaction — reinstate a withheld sample, un-mask one
vector position, restore a dropped predicate — and assert the conformance checks
catch it. A redactor nobody has tried to defeat is an assumption, not evidence,
which is precisely the argument the validation methodology already makes about
the validator itself.
Adversarial checks worth writing early, because they catch the leaks that correct-looking implementations produce anyway:
- Does any IRI in the released graph contain a withheld sample id?
- Do row identifiers reconstruct the source ordering?
- Is any masked vector position identifiable as masked-for-one-sample (§8)?
- Does the header section still name a withheld participant?
vcf-rdfizer-policy, mirroring vcf-rdfizer-rules and the proposed
vcf-rdfizer-link:
| Command | Purpose |
|---|---|
policy init -o p.ttl |
Scaffold with annotated examples and a default-deny skeleton |
policy check p.ttl |
Validate against the profile: unknown selectors, unresolvable DUO terms, assembly declared, conflicts, and any rule no tier can enforce |
policy explain p.ttl |
Render the resolved release plan in English: what is dropped, degraded, thresholded, and what remains |
policy dry-run p.ttl -i sample.vcf |
Report counts that would be withheld, per rule, writing nothing |
policy diff p1.ttl p2.ttl |
What changes between two policy versions — the question a data access committee actually asks |
explain is the one that earns its keep. A policy nobody can read is a policy
nobody can approve, and the failure mode is approval-by-exhaustion.
| Step | Delivers | Unlocks |
|---|---|---|
| 1 | vcfp: profile, policy parsing, Predicate/Class/Sample/Header selectors, vcfp:drop, default-deny, conflict rules |
Coarse governed release with no new machinery |
| 2 | Tier 1 (TSV) + Tier 2 (emitter) enforcement, CHROM/POS on ParsedSampleRecord, RegionSelector |
Region and field granularity at no runtime cost |
| 3 | Release manifest, policy conformance preflights, mutation entries | Releases become auditable and verified rather than asserted |
| 4 | vcf-rdfizer-policy CLI, explain, diff |
Policies become reviewable by non-implementers |
| 5 | vcfp:pseudonymize with IRI re-minting, maskVectorPositions, threshold |
The leaks in §7 and §8 closed |
| 6 | --mode redact (Tier 3), then optional query-time rewriting |
Existing artifacts, and endpoint deployments |
Steps 1–3 are the contract and the evidence. Note that step 3 comes before the sophisticated transforms in step 5 — deliberately. A crude redaction that is verified is worth more than a sophisticated one that is not.
Stated up front, as in the linking design.
This is not anonymization, and the vocabulary must not drift. Every user- facing string, manifest field and log line should say pseudonymized, withheld, or governed release. The moment the tool says "anonymized", it is making a claim it cannot support (§1), and someone will rely on it.
Compliance is not a feature. GDPR special-category processing, national genomic-data law, and the scope of a specific consent form are determinations for a data protection officer and a data access committee. The tool can implement a policy and evidence what it did. It cannot decide what the policy should be, and the documentation must not imply otherwise.
Structural leakage survives redaction. A withheld region still shows as a
gap; a withheld sample still shows as a missing SampleSet member unless the
set itself is rewritten; a thresholded variant still shows as absent. Whether
those gaps matter is a policy question, but the tool should surface them —
policy explain is the right place.
Composition across releases is unmanaged. Two separately compliant releases to the same recipient can jointly disclose more than either alone: different regions, different thresholds, or a fresh pseudonym key that is nonetheless linkable through the genotypes. Nothing in this design tracks cumulative disclosure, and the manifest is the only thread that would make it possible later. Say so rather than implying releases are independent.
Policy drift versus artifact. A policy IRI resolves to whatever it resolves to today; the artifact was produced under what it said then. The digest in §10 is what makes that recoverable, and it is why it is mandatory rather than nice to have.
The escape hatch is a footgun. vcfp:PatternSelector accepts arbitrary
SPARQL, which cannot be pushed into Tier 1 or 2 and whose cost is unbounded. It
should be permitted, reported prominently by explain, and excluded from any
profile intended for unattended use.
- Data linking design — the sibling proposal; same plugin, provenance and verification patterns
- Conversion — the IRI templates and graph shapes a policy addresses
- Sample representations — why condensed mode changes the enforceability of per-sample rules
- Validation methodology — the harness that makes a privacy claim measurable
- Limitations — what the tool does not do today
- Roadmap — how this relates to the other planned work