Skip to main content

internal/infra/pg/erasurestore.go

internal/infra/pg · 572 lines · 26 declarations · source

Declarations

type Eraser

type Eraser struct{ pool *pgxpool.Pool }

Eraser deletes everything derived from a data subject's observations — or from a named set of source observations — and counts what survived.

source

func NewEraser

func NewEraser(pool *pgxpool.Pool) *Eraser

NewEraser builds an eraser.

source

type ErasureReceipt

type ErasureReceipt struct {
RequestID string `json:"id"`
Scope string `json:"project"`
Selector json.RawMessage `json:"selector"`
Reason string `json:"reason"`
RequestedAt time.Time `json:"requested_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Residual json.RawMessage `json:"residual,omitempty"`
}

ErasureReceipt is one erasure as the ledger of erasures holds it: what was asked, when, and the counted residual once it completed. The selector names a subject or a source, never content.

source

method Eraser.Receipts

func (e *Eraser) Receipts(ctx context.Context, schema Schema, scope string, limit int) ([]ErasureReceipt, error)

Receipts lists one project's erasures, newest first.

source

type projectionKind

type projectionKind struct {
kind string
table string
idCol string
// survivesSharing is whether a projection of this kind is kept when another data subject also
// registered it. True for a shared identity — an entity means the same thing to everybody who
// registered it. False for anything holding text drawn from several people, where keeping it
// because somebody else also contributed leaves the departing subject's words in a row the
// erasure counted as retained.
survivesSharing bool
}

projectionKind is a declared kind and the table it lives in, read from `projection_kind`.

source

type sourceSelection

type sourceSelection struct {
subject string
sources []string
}

sourceSelection names what an erasure removes: one data subject, or a set of source observations.

Both walk the same path and count against the same predicates, because the receipt is the product and two walks would be two receipts with different meanings under one name. What differs is only how an observation is picked — by the person it is attributed to, or by its own identity — and that is rendered here, once, so every statement in the walk takes (scope, selector) and nothing else. A source selector never touches the data-subject row: the person's other turns survive, so the person does. sourceSelection is how a governance walk names what it is for: one person, or a list of turns. Erasure and export both take it, so the two walks cannot disagree about what "this person's data" means — an export that reached rows an erasure would miss is the failure a receipt exists to rule out.

source

method sourceSelection.bySource

func (s sourceSelection) bySource() bool

source

method sourceSelection.arg

func (s sourceSelection) arg() any

arg is what every statement binds as $2.

source

method sourceSelection.observations

func (s sourceSelection) observations(prefix string) string

observations is the predicate that picks the selected observations, on an observation aliased by prefix ("o." or "").

source

method sourceSelection.subjects

func (s sourceSelection) subjects() string

subjects is the subquery of the people the selection covers, for tables keyed by subject rather than by source. A subject selection is that person; a source selection is whoever the selected turns were about, which is how a document's export reaches the subject rows it registered.

source

method sourceSelection.owned

func (s sourceSelection) owned() string

owned is the subquery of selected observation ids, for tables that point at a source.

source

method sourceSelection.registrations

func (s sourceSelection) registrations() string

registrations picks the projection registrations the selection made, on `projection_dependency d`.

source

method sourceSelection.foreign

func (s sourceSelection) foreign() string

foreign is a registration NOT made by the selection — the one that keeps a shared projection alive.

source

method sourceSelection.record

func (s sourceSelection) record() ([]byte, error)

record is the selector as the receipt keeps it: a subject or a list of sources, never content.

source

method Eraser.Erase

func (e *Eraser) Erase(ctx context.Context, schema Schema, scope, dataSubjectID, reason string) (domain.Erasure, error)

Erase removes a data subject from a project and returns the receipt.

Everything in one transaction, including the count

The residual is counted after the deletes and before the commit. Counted afterwards it would be a separate read of a moving target: a concurrent formation writing a new projection for the same subject would show up as a residual the erasure did not leave, or worse, a concurrent erasure of the same subject would make each of them report the other's work as their own clean sweep.

The order matters and is not obvious

The projections are deleted through their registrations, then counted through the SAME registrations, and only then are the observations deleted. Deleting the observations first would cascade the registrations away, and the count would then be against a predicate that selects nothing — a residual of zero that means "I could not find anything to check" rather than "nothing survived".

What is deliberately NOT deleted

A projection registered to an observation belonging to somebody else as well is kept. An entity is the case that matters: `Ensera` is registered by every subject who mentioned it, and removing it because one of them left would take a node out of everybody else's graph. So the predicate deletes only projections whose every registration belongs to this subject — and the residual is counted with that same predicate, so a shared entity surviving is correctly not a residual.

source

method Eraser.EraseSources

func (e *Eraser) EraseSources(ctx context.Context, schema Schema, scope string, sources []string, reason string) (domain.Erasure, error)

EraseSources removes a set of observations by their own identity — a document that was observed project-wide, which no subject erasure can reach — and returns the same receipt.

The walk is the subject walk with the observations picked by id, so the receipt means the same thing: every projection registered only to these sources is gone, a projection another source also registered is kept, and the residual is counted against the same predicate. An id from another project matches nothing here: the predicate is scoped before it is keyed, so the receipt reports zero deleted rather than reaching across.

source

method Eraser.erase

func (e *Eraser) erase(ctx context.Context, schema Schema, scope string, sel sourceSelection, reason string) (domain.Erasure, error)

source

func declaredKinds

func declaredKinds(ctx context.Context, tx pgx.Tx, schema Schema) ([]projectionKind, error)

declaredKinds reads the projection registry.

Read per erasure rather than cached, because it is four rows and because a cache would be a copy of the one thing that must not be stale: a kind added by a migration and missing from an eraser's memory is exactly the silent gap this table exists to close.

source

const openErasureSQL

const openErasureSQL = `
INSERT INTO {schema}.erasure_request (request_id, scope, selector, reason)
VALUES ($1, $2, $3::jsonb, $4)`

source

const closeErasureSQL

const closeErasureSQL = `
UPDATE {schema}.erasure_request SET completed_at = $2, residual = $3 WHERE request_id = $1`

source

func deleteProjectionSQL

func deleteProjectionSQL(schema Schema, k projectionKind, sel sourceSelection) string

deleteProjectionSQL removes the rows of one kind that belong to the selection alone.

The table and column are interpolated because PostgreSQL parameters are values and never identifiers. They come from `projection_kind`, whose CHECK constraints permit nothing but `[a-z][a-z0-9_]*` — so the names could not have been anything else, rather than being made safe on the way out.

`NOT EXISTS` is the sharing rule: a projection with any registration belonging to a different subject — or to none — stays. An entity is the case that matters, and removing a shared one would take a node out of everybody else's graph to satisfy one person's request.

source

func selectErasableSQL

func selectErasableSQL(k projectionKind, sel sourceSelection) string

selectErasableSQL is the predicate that decides what an erasure removes, and it is used verbatim by the count afterwards.

The clause a kind can switch off

A projection another subject also registered is kept — `Ensera` is registered by everybody who mentioned it, and removing it because one of them left would take a node out of everybody else's graph. That is right for a shared IDENTITY, whose row means the same thing to each registrant.

It is wrong for shared TEXT. A report written from several people's words contains each of them, so keeping it because somebody else also contributed leaves the departing subject's material in a row the erasure walked past and counted as correctly retained. The kind declares which it is (`survives_sharing`), so the question is answered where the table name is rather than in the eraser, and a kind added later cannot avoid answering it.

source

func countResidualSQL

func countResidualSQL(schema Schema, k projectionKind, sel sourceSelection) string

countResidualSQL counts, against the same predicate, what is still there.

The same predicate is the whole point. A count with a different one measures something else and reports it under the name of this erasure — and it would report zero for all the usual reasons a query returns nothing.

source

func quoteLiteral

func quoteLiteral(s string) string

quoteLiteral renders a kind as a SQL string literal.

The kind is also constrained to `[a-z][a-z0-9_]*` at its source, so this cannot encounter a quote to double. It doubles them anyway: a defence that depends on a constraint in another file being read correctly is one edit away from not being a defence.

source

method Eraser.Erasures

func (e *Eraser) Erasures(ctx context.Context, schema Schema, scope string) ([]domain.Erasure, error)

Erasures returns the receipts recorded for a scope, newest first.

A receipt is a row rather than a log line because it is the artefact the product is sold on: an erasure in flight has no completion time, and one with a non-zero residual is an erasure that did not do what it claimed. Both have to be findable by asking, not by grepping.

source

const selectErasuresSQL

const selectErasuresSQL = `
SELECT request_id::text, scope, selector, reason, completed_at, residual
FROM {schema}.erasure_request
WHERE scope = $1
ORDER BY requested_at DESC`

source