Provenance fields that can be overwritten are not provenance — they are annotations. If an enrichment job can silently replace source_id or acquired_at on an existing contact, the audit trail is broken. GDPR Art. 5(1)(d) requires data accuracy, which includes accuracy about the origin of the data. SLSA Provenance L2 requires that provenance records be unforgeable after creation. CWE-345 (Insufficient Verification of Data Authenticity) applies when records can be modified post-creation in ways that destroy traceability.
High because mutable provenance fields allow enrichment jobs or admin tools to silently rewrite the record of origin, breaking GDPR Art. 30 compliance and destroying the evidentiary value of the audit trail.
Exclude provenance fields from all update schemas at the application layer, and optionally enforce immutability at the database layer with a trigger.
// Zod update schema in src/lib/contacts/update.ts
const updateContactSchema = z.object({
email: z.string().email().optional(),
first_name: z.string().optional(),
last_name: z.string().optional(),
// source_type, source_id, acquired_at intentionally excluded
})
-- Database-level enforcement
CREATE OR REPLACE FUNCTION prevent_provenance_update()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.source_type IS DISTINCT FROM OLD.source_type OR
NEW.source_id IS DISTINCT FROM OLD.source_id OR
NEW.acquired_at IS DISTINCT FROM OLD.acquired_at THEN
RAISE EXCEPTION 'Provenance fields are immutable after creation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_provenance_immutability
BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION prevent_provenance_update();
ID: data-sourcing-provenance.provenance-tracking.provenance-immutable
Severity: high
What to look for: Enumerate all UPDATE statements or ORM update calls on the contacts table. Count every update path and for each, check whether provenance fields (source_type, source_id, acquired_at) are in the write set. Quote the actual update schema or Zod validation found. Check whether the contact update API endpoint, background enrichment jobs, or admin tools allow overwriting provenance data. Also check database triggers or column-level permissions that might enforce immutability at the database level. An update path that includes provenance fields does not count as pass.
Pass criteria: Count all update paths and report the ratio: "0 of N update paths modify provenance fields." No application code path issues UPDATE statements that modify source_type, source_id, or acquired_at on existing contact records. Ideally enforced at the database level via a trigger or generated-always column, but application-level enforcement is acceptable.
Fail criteria: At least 1 enrichment job, update endpoint, or admin tool can overwrite provenance fields on existing records. The ORM update schema includes provenance fields in updatable fields.
Skip (N/A) when: The system has only one data source and provenance fields were not implemented (would already fail the required-provenance-fields check).
Detail on fail: "Contact update endpoint includes source_type in updatable fields — provenance can be overwritten" or "Enrichment worker overwrites acquired_at when it re-processes a record".
Remediation: Remove provenance fields from all update schemas, and optionally enforce immutability in the database:
// Zod update schema — provenance fields excluded
const updateContactSchema = z.object({
email: z.string().email().optional(),
first_name: z.string().optional(),
// source_type, source_id, acquired_at intentionally omitted
})
-- Trigger to block provenance overwrite
CREATE OR REPLACE FUNCTION prevent_provenance_update()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.source_type IS DISTINCT FROM OLD.source_type OR
NEW.source_id IS DISTINCT FROM OLD.source_id OR
NEW.acquired_at IS DISTINCT FROM OLD.acquired_at THEN
RAISE EXCEPTION 'Provenance fields are immutable after creation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_provenance_immutability
BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION prevent_provenance_update();